Compare commits
2 Commits
master
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
| e41ea6c8b3 | |||
| f5670712f6 |
399
Development/client/docs/PILOT_DASHBOARD_QUICK_REFERENCE.md
Normal file
399
Development/client/docs/PILOT_DASHBOARD_QUICK_REFERENCE.md
Normal file
@ -0,0 +1,399 @@
|
||||
# Pilot Analytics Dashboard - Quick Reference Summary
|
||||
|
||||
**Project Name:** Pilot Analytics Dashboard
|
||||
**Duration:** 4-5 weeks (22-27 working days)
|
||||
**Target Go-Live:** Late May 2026
|
||||
**Team:** Frontend (8 days setup + core delivery) + Backend parallel (8-13 days if needed)
|
||||
|
||||
## Source Files
|
||||
|
||||
This quick reference is derived from the following source files:
|
||||
|
||||
- Primary: `Pilot_Dashboard_PO_Brief_v1.md` (v1.5)
|
||||
- Primary: `Pilot_Dashboard_Requirements_v1.md` (v1.5)
|
||||
- Supporting: `plan-pilotAnalyticsDashboard.prompt.md`
|
||||
- Supporting: `Pilot-Dashboard-UI-UX-Design-Specification.md`
|
||||
- Supporting visual reference: `Sample Pilot Dashboard.png` and the PyQt mockup
|
||||
|
||||
When source documents conflict, the v1.5 PO brief and v1.5 technical requirements take precedence.
|
||||
|
||||
---
|
||||
|
||||
## At a Glance
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TOTAL PROJECT TIMELINE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Week 1: Phase 1 (Foundation) ████░░░░░░░░░░░ [Days 1-5] │
|
||||
│ Week 2: Phase 2 (Core UI) ███████░░░░░░░░░░ [Days 6-12] │
|
||||
│ Week 3: Phase 3 (Analytics) ██████░░░░░░░░░░░ [Days 13-20] │
|
||||
│ Week 4: Phase 4 (QA+Release) ████░░░░░░░░░░░░ [Days 21-27] │
|
||||
│ Week 5: UAT & Fix ██░░░░░░░░░░░░░░░ [Days 28-30] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Four Phases Overview
|
||||
|
||||
| Phase | Focus | Duration | Key Deliverable | Risk Level |
|
||||
|-------|-------|----------|-----------------|------------|
|
||||
| **Phase 1** | Foundation, API contracts, role branching | 5-6 days | Service layer + mock data + spec | Low |
|
||||
| **Phase 2** | Core UI (layout, KPI, active jobs) | 7-8 days | Responsive dashboard shell | Medium |
|
||||
| **Phase 3** | Charts, indicators, i18n, error handling | 6-8 days | Full-featured dashboard | Medium |
|
||||
| **Phase 4** | Testing, integration, release prep | 4-5 days | Production-ready build | Low |
|
||||
|
||||
---
|
||||
|
||||
## Key Milestones
|
||||
|
||||
| Milestone | Date | Criteria |
|
||||
|-----------|------|----------|
|
||||
| **M1: Service Layer Ready** | EOW 1 (May 2) | All 5 endpoints spec'd, mocks created, service layer tested |
|
||||
| **M2: UI Complete** | EOW 2 (May 9) | KPI, summary, active jobs, responsive tested |
|
||||
| **M3: Analytics Complete** | EOW 3 (May 16) | Charts, indicators, i18n, error states all working |
|
||||
| **M4: QA Complete** | EOW 4 (May 23) | E2E tests pass, cross-browser OK, performance audit complete |
|
||||
| **M5: Release Ready** | Mid-May 28 | Staging deployment, UAT approved, release notes ready |
|
||||
|
||||
---
|
||||
|
||||
## Phase Breakdown (Quick View)
|
||||
|
||||
### Phase 1: Foundation (Days 1-6)
|
||||
**Tasks:** 7
|
||||
**Components:** 0 (service layer only)
|
||||
**Deliverables:** Dashboard service, mock data, backend spec
|
||||
```
|
||||
Day 1: Extend JobStatus enum
|
||||
Days 2-3: Dashboard service + contracts
|
||||
Day 4: Role-based home branching
|
||||
Days 5-6: Backend API spec
|
||||
```
|
||||
|
||||
### Phase 2: Core UI (Days 7-14)
|
||||
**Tasks:** 11
|
||||
**Components:** 3 (KPI card, summary, active jobs)
|
||||
**Deliverables:** Responsive layout, all primary sections
|
||||
```
|
||||
Day 7: Layout shell
|
||||
Days 8-9: KPI + summary strips
|
||||
Days 10-12: Active jobs panel with interactions
|
||||
Days 13-14: Responsive + accessibility testing
|
||||
```
|
||||
|
||||
### Phase 3: Analytics & Polish (Days 15-22)
|
||||
**Tasks:** 12
|
||||
**Components:** 2 new (charts, indicators) + refinements
|
||||
**Deliverables:** Charting, performance metrics, full i18n, error handling
|
||||
```
|
||||
Days 15-16: Charts + date range control
|
||||
Days 17-18: Performance indicators (XT, altitude)
|
||||
Days 19-20: Loading/empty/error states
|
||||
Days 21-22: i18n localization (EN/PT/ES)
|
||||
```
|
||||
|
||||
### Phase 4: QA & Release (Days 23-27)
|
||||
**Tasks:** 9
|
||||
**Components:** 0 (QA & testing focus)
|
||||
**Deliverables:** Tested, documented, production build
|
||||
```
|
||||
Days 23-24: Integration & E2E testing
|
||||
Days 25-26: Cross-browser + perf testing
|
||||
Day 27: Documentation & release prep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure (Expected)
|
||||
|
||||
```
|
||||
src/app/dashboard/
|
||||
├── dashboard.component.ts (updated: role branching)
|
||||
├── dashboard.component.html (updated: template branching)
|
||||
├── dashboard.component.css (minimal changes)
|
||||
│
|
||||
├── pilot-dashboard/ (NEW FOLDER)
|
||||
│ ├── pilot-dashboard.component.ts (main container)
|
||||
│ ├── pilot-dashboard.component.html
|
||||
│ └── pilot-dashboard.component.scss
|
||||
│
|
||||
└── components/ (NEW FOLDER)
|
||||
├── kpi-card/
|
||||
│ ├── kpi-card.component.ts
|
||||
│ ├── kpi-card.component.html
|
||||
│ └── kpi-card.component.scss
|
||||
│
|
||||
├── daily-summary/
|
||||
│ ├── daily-summary.component.ts
|
||||
│ ├── daily-summary.component.html
|
||||
│ └── daily-summary.component.scss
|
||||
│
|
||||
├── operations-today/
|
||||
│ ├── operations-today.component.ts
|
||||
│ ├── operations-today.component.html
|
||||
│ └── operations-today.component.scss
|
||||
│
|
||||
├── active-jobs/
|
||||
│ ├── active-jobs.component.ts
|
||||
│ ├── job-row.component.ts
|
||||
│ ├── active-jobs.component.html
|
||||
│ └── active-jobs.component.scss
|
||||
│
|
||||
├── hours-chart/
|
||||
│ ├── hours-chart.component.ts
|
||||
│ ├── hours-chart.component.html
|
||||
│ └── hours-chart.component.scss
|
||||
│
|
||||
├── hectares-chart/
|
||||
│ ├── hectares-chart.component.ts
|
||||
│ ├── hectares-chart.component.html
|
||||
│ └── hectares-chart.component.scss
|
||||
│
|
||||
├── date-range-selector/
|
||||
│ ├── date-range-selector.component.ts
|
||||
│ ├── date-range-selector.component.html
|
||||
│ └── date-range-selector.component.scss
|
||||
│
|
||||
├── xt-error-indicator/
|
||||
│ ├── xt-error-indicator.component.ts
|
||||
│ ├── xt-error-indicator.component.html
|
||||
│ └── xt-error-indicator.component.scss
|
||||
│
|
||||
└── altitude-indicator/
|
||||
├── altitude-indicator.component.ts
|
||||
├── altitude-indicator.component.html
|
||||
└── altitude-indicator.component.scss
|
||||
|
||||
src/app/domain/
|
||||
├── models/
|
||||
│ └── pilot-dashboard.model.ts (NEW: TS interfaces)
|
||||
│
|
||||
└── services/
|
||||
└── pilot-dashboard.service.ts (NEW: API service)
|
||||
└── pilot-dashboard.service.spec.ts (NEW: tests)
|
||||
|
||||
src/app/shared/
|
||||
├── mock/
|
||||
│ └── pilot-dashboard-mock.ts (NEW: mock data)
|
||||
│
|
||||
└── global.ts (UPDATED: JobStatus enum)
|
||||
|
||||
docs/
|
||||
├── PILOT_DASHBOARD_ROADMAP.md (THIS FILE)
|
||||
├── PILOT_DASHBOARD_TASK_CHECKLIST.md
|
||||
└── PILOT_DASHBOARD_API_SPEC.md (NEW: backend contract)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Total Code Changes Summary
|
||||
|
||||
| Category | Lines Changed | Impact |
|
||||
|----------|----------------|--------|
|
||||
| **New TypeScript** | ~1500-2000 | 8-10 new component classes + service |
|
||||
| **New Templates** | ~800-1200 | HTML for 8 components |
|
||||
| **New Styles** | ~600-800 | SCSS for responsive layout + indicators |
|
||||
| **Updated Files** | ~150-200 | global.ts, dashboard.component.* |
|
||||
| **Tests** | ~1000-1500 | Jasmine specs for service + components |
|
||||
| **Documentation** | ~500 | API spec + user guide + comments |
|
||||
| **Total** | **~4500-6700** | 12-18 feature files + 2-3 updated files |
|
||||
|
||||
---
|
||||
|
||||
## PR Strategy (4 PRs Total)
|
||||
|
||||
| PR | Phase | Files Changed | Commits | Review Focus |
|
||||
|----|-------|----------------|---------|--------------|
|
||||
| **PR1** | Foundation | 5-10 files | 4 commits | Service layer, constants, role gating |
|
||||
| **PR2** | Core UI | 15-20 files | 3 commits | Layout, KPI, active jobs, responsive |
|
||||
| **PR3** | Analytics | 10-15 files | 3 commits | Charts, indicators, i18n, error handling |
|
||||
| **PR4** | Release | 5-10 files | 3 commits | Tests, docs, final polish, build |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
**Functionality:**
|
||||
- [ ] Pilot users see new dashboard (role isolation works)
|
||||
- [ ] Non-pilots see original disclaimer (backward compatible)
|
||||
- [ ] All 5 API endpoints wired and displaying data
|
||||
- [ ] Active jobs clickable → navigate to job detail
|
||||
- [ ] Charts render with date range control
|
||||
- [ ] Performance indicators show correct color bands
|
||||
- [ ] i18n works (EN, PT, ES selectable)
|
||||
|
||||
**Quality:**
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Performance audit completed
|
||||
- [ ] Mobile responsive (320px - 1920px)
|
||||
- [ ] Cross-browser (Chrome, Safari, Firefox, Edge)
|
||||
- [ ] Accessibility: WCAG AA compliant
|
||||
- [ ] Appropriate dashboard test coverage added
|
||||
|
||||
**Release Readiness:**
|
||||
- [ ] Production build succeeds for all locales
|
||||
- [ ] No breaking changes to existing features
|
||||
- [ ] Documentation complete (user guide + tech docs)
|
||||
- [ ] Release notes published
|
||||
- [ ] Staging deployment successful
|
||||
|
||||
---
|
||||
|
||||
## Top Risks & Mitigations
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|-----------|
|
||||
| Backend endpoints not ready | Medium | Phase 4 blocked | Use mock data through Phase 3; swap at integration |
|
||||
| i18n extraction tool issues | Low | Phase 3 delayed | Pre-test localization workflow in Phase 1 |
|
||||
| Chart library compatibility | Low | Phase 3 blocked | Verify chart.js 2.9.3 + PrimeNG 9 integration early |
|
||||
| Mobile responsive issues late | Medium | Phase 4 delayed | Test mobile throughout Phase 2, not at end |
|
||||
| Job detail route not working | Low | Phase 2 blocker | E2E test navigation at end of Phase 2 |
|
||||
| Status enum break existing jobs | High | Regression | Coordinate with backend; test all statuses |
|
||||
|
||||
---
|
||||
|
||||
## Communication Plan
|
||||
|
||||
**Daily Standup:**
|
||||
- 10 mins, same time each day
|
||||
- Report: completed, in-progress, blockers
|
||||
- Update task checklist
|
||||
|
||||
**Weekly Demo (Friday EOD):**
|
||||
- Show completed phase sections to PO/team
|
||||
- Gather feedback for next phase
|
||||
- Update roadmap if needed
|
||||
|
||||
**Backend Sync (if parallel):**
|
||||
- Mon & Thu: align on API spec and integration readiness
|
||||
- Verify mock data contracts match backend plan
|
||||
|
||||
---
|
||||
|
||||
## Development Setup Checklist
|
||||
|
||||
- [ ] Clone repo / pull latest
|
||||
- [ ] `npm install` dependencies
|
||||
- [ ] `ng serve` runs without errors
|
||||
- [ ] Verify existing tests pass: `ng test`
|
||||
- [ ] Read PILOT_DASHBOARD_API_SPEC.md (when created in Phase 1)
|
||||
- [ ] Review requirements docs in `/attachments/`
|
||||
- [ ] Have Pilot Dashboard UI mockup open (reference)
|
||||
- [ ] Setup code editor: prettier, tslint extensions
|
||||
- [ ] Create feature branch: `git checkout -b feature/pilot-dashboard`
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions Made (Frozen for Phase 1)
|
||||
|
||||
1. **Active Jobs Display:** UI states are NEW, IN PROGRESS, COMPLETED, where IN PROGRESS groups READY, DOWNLOADED, and SPRAYED backend states.
|
||||
2. **Altitude Priority:** sprayHeight → radarAlt → no-data state
|
||||
3. **Operations Today:** Distance and spray volume only in Phase 1; Flights Today is excluded due to unreliable source data.
|
||||
4. **i18n:** EN/PT/ES from day 1
|
||||
5. **Charts:** PrimeNG p-chart (wraps chart.js 2.9.3)
|
||||
6. **Status Colors:** Blue (NEW), Yellow (IN PROGRESS), Green (COMPLETED)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Commands
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <repo>
|
||||
cd client
|
||||
npm install
|
||||
|
||||
# Development
|
||||
npm start # http://localhost:4200
|
||||
|
||||
# Testing
|
||||
ng test # Unit tests
|
||||
ng test --code-coverage
|
||||
ng lint
|
||||
|
||||
# Build
|
||||
ng build --prod --localize # Production + all locales
|
||||
|
||||
# i18n workflow
|
||||
npm run sync-i18n # Extract + merge
|
||||
|
||||
# Useful for reference
|
||||
cat docs/PILOT_DASHBOARD_ROADMAP.md # Detailed roadmap
|
||||
cat docs/PILOT_DASHBOARD_TASK_CHECKLIST.md # Detailed tasks
|
||||
cat docs/PILOT_DASHBOARD_API_SPEC.md # Backend contract (Phase 1.6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- **docs/PILOT_DASHBOARD_ROADMAP.md** ← Full detailed roadmap
|
||||
- **docs/PILOT_DASHBOARD_TASK_CHECKLIST.md** ← Day-by-day task checklist
|
||||
- **Pilot_Dashboard_PO_Brief_v1.md** ← Business requirements (in attachments)
|
||||
- **Pilot_Dashboard_Requirements_v1.md** ← Technical spec (in attachments)
|
||||
- **Pilot-Dashboard-UI-UX-Design-Specification.md** ← Design rules (in attachments)
|
||||
- **Sample Pilot Dashboard.png** ← Reference mockup (in attachments)
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (End of Each Phase)
|
||||
|
||||
### End of Phase 1
|
||||
- [ ] Service layer complete and tested
|
||||
- [ ] Mock data provider ready
|
||||
- [ ] Backend API spec documented and approved
|
||||
- [ ] Role branching working (pilot sees placeholder, others see disclaimer)
|
||||
- [ ] All Phase 1 PRs merged
|
||||
|
||||
### End of Phase 2
|
||||
- [ ] All UI sections render correctly
|
||||
- [ ] Layout responsive on all breakpoints
|
||||
- [ ] KPI cards, summary, operations, active jobs all visible
|
||||
- [ ] Job row clicks navigate to job detail
|
||||
- [ ] Accessibility audit passed
|
||||
- [ ] All Phase 2 PRs merged
|
||||
|
||||
### End of Phase 3
|
||||
- [ ] Charts render and respond to date range changes
|
||||
- [ ] Performance indicators show data with correct thresholds
|
||||
- [ ] i18n working (all 3 languages)
|
||||
- [ ] Error and loading states display correctly
|
||||
- [ ] No console errors
|
||||
- [ ] All Phase 3 PRs merged
|
||||
|
||||
### End of Phase 4
|
||||
- [ ] Integration testing passed
|
||||
- [ ] Cross-browser testing passed
|
||||
- [ ] E2E tests passed
|
||||
- [ ] Performance audit completed
|
||||
- [ ] Production build succeeds for all locales
|
||||
- [ ] Documentation complete
|
||||
- [ ] All Phase 4 PRs merged
|
||||
- [ ] Ready for staging deployment
|
||||
|
||||
---
|
||||
|
||||
## Effort Distribution
|
||||
|
||||
```
|
||||
Foundation & Contracts: 22% (5-6 days)
|
||||
├─ Service layer, models, role gating
|
||||
|
||||
Core UI Delivery: 30% (7-8 days)
|
||||
├─ Layout, KPI, summary, active jobs
|
||||
|
||||
Analytics & Polish: 28% (6-8 days)
|
||||
├─ Charts, indicators, i18n, error handling
|
||||
|
||||
QA & Release: 20% (4-5 days)
|
||||
├─ Testing, documentation, build prep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Status:** Ready to Start
|
||||
**Created:** April 28, 2026
|
||||
**Last Updated:** April 28, 2026
|
||||
304
Development/client/docs/PILOT_DASHBOARD_ROADMAP.md
Normal file
304
Development/client/docs/PILOT_DASHBOARD_ROADMAP.md
Normal file
@ -0,0 +1,304 @@
|
||||
# Pilot Analytics Dashboard - Development Roadmap
|
||||
|
||||
**Project Duration:** ~4-5 weeks (25-30 working days)
|
||||
**Start Date:** April 28, 2026
|
||||
**Target Delivery:** Late May 2026
|
||||
|
||||
## Source Files
|
||||
|
||||
This roadmap is derived from the following source files:
|
||||
|
||||
- Primary: `Pilot_Dashboard_PO_Brief_v1.md` (v1.5)
|
||||
- Primary: `Pilot_Dashboard_Requirements_v1.md` (v1.5)
|
||||
- Supporting: `plan-pilotAnalyticsDashboard.prompt.md`
|
||||
- Supporting: `Pilot-Dashboard-UI-UX-Design-Specification.md`
|
||||
- Supporting visual reference: `Sample Pilot Dashboard.png` and the PyQt mockup
|
||||
|
||||
When source documents conflict, the v1.5 PO brief and v1.5 technical requirements take precedence.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap Overview
|
||||
|
||||
This roadmap is divided into 4 phases with specific deliverables and time estimates. Each phase culminates in a PR-ready checkpoint.
|
||||
|
||||
### Phase Breakdown
|
||||
- **Phase 1 (Foundation):** 5-6 days
|
||||
- **Phase 2 (Core UI):** 7-8 days
|
||||
- **Phase 3 (Analytics & Polish):** 6-8 days
|
||||
- **Phase 4 (QA & Release):** 4-5 days
|
||||
|
||||
**Total:** 22-27 working days (frontend only)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation & Contracts (5-6 Days)
|
||||
|
||||
### Deliverables
|
||||
- Extended job statuses (COMPLETED, INVOICED)
|
||||
- Dashboard data models and API service layer
|
||||
- Role-based home branching
|
||||
- Backend endpoint specification document
|
||||
- Dev environment with mock data setup
|
||||
|
||||
### Tasks
|
||||
|
||||
| Task | Owner | Days | Dependencies | Notes |
|
||||
|------|-------|------|--------------|-------|
|
||||
| **1.1** Extend JobStatus enum + global constants | Frontend | 1.0 | None | Update global.ts, JobStatuses map, status pipes |
|
||||
| **1.2** Design dashboard data contracts (TypeScript interfaces) | Frontend | 1.0 | None | Define KPI, Summary, Operations, ActiveJobs, Trend, Performance models |
|
||||
| **1.3** Implement dashboard service with 5 endpoints | Frontend | 1.5 | 1.2 | GET /api/dashboard/pilot/[kpi, summary, active-jobs, trend, performance] |
|
||||
| **1.4** Add service unit tests with mock data | Frontend | 0.75 | 1.3 | Jasmine tests for service methods; mock HttpClient |
|
||||
| **1.5** Update dashboard component role branching | Frontend | 0.75 | 1.1 | Inject AuthService, template *ngIf branching |
|
||||
| **1.6** Backend spec: finalize 5 endpoints (NO implementation) | Backend/Frontend | 1.0 | 1.2 | Document request/response contracts; ready for backend sprint |
|
||||
| **1.7** Setup mock data provider for dev | Frontend | 0.5 | 1.2 | Mock service for UI development without backend |
|
||||
|
||||
**Phase 1 Checkpoint:** PR ready, all constants aligned, service contracts finalized, no backend dependency blocking UI work.
|
||||
|
||||
**Commit Messages:**
|
||||
```
|
||||
refactor(global): add completed and invoiced job statuses
|
||||
feat(dashboard): add pilot dashboard api service and response models
|
||||
feat(home): add pilot-only dashboard branch with fallback disclaimer
|
||||
docs(dashboard): specify pilot dashboard backend api contracts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core UI Delivery (7-8 Days)
|
||||
|
||||
### Deliverables
|
||||
- Responsive 2-column layout
|
||||
- KPI cards, daily summary, operations panels
|
||||
- Active jobs list with status coloring and progress bars
|
||||
- All core sections visually complete and clickable
|
||||
|
||||
### Tasks
|
||||
|
||||
| Task | Owner | Days | Dependencies | Notes |
|
||||
|------|-------|------|--------------|-------|
|
||||
| **2.1** Build responsive grid layout (65/35 split) | Frontend | 1.5 | 1.5 | PrimeNG p-grid, mobile stack order |
|
||||
| **2.2** Create KPI card component + template | Frontend | 1.0 | 1.1 | Icons, historical sub-lines, responsive cards |
|
||||
| **2.3** Implement daily summary strip (today vs yesterday) | Frontend | 0.75 | 1.1 | Trend arrows, percent deltas, green/red coloring |
|
||||
| **2.4** Add operations today panel (distance + spray volume) | Frontend | 0.5 | 1.1 | 2-metric strip below summary |
|
||||
| **2.5** Build active jobs list panel | Frontend | 2.0 | 1.1 | Status badges, left color bar, progress bars, row click → job detail |
|
||||
| **2.6** Implement job row status colors (NEW=Blue, IN PROGRESS=Yellow, COMPLETED=Green) | Frontend | 0.75 | 2.5 | IN PROGRESS groups READY, DOWNLOADED, SPRAYED |
|
||||
| **2.7** Add scroll behavior to active jobs panel | Frontend | 0.5 | 2.5 | Independently scrollable; handles 10+ jobs |
|
||||
| **2.8** Unit + integration tests for layout (responsive, mobile) | Frontend | 0.5 | 2.1-2.7 | Karma tests, viewport mocking |
|
||||
| **2.9** Accessibility audit (ARIA, keyboard nav, contrast) | Frontend | 0.5 | 2.1-2.7 | Lighthouse, manual review |
|
||||
|
||||
**Phase 2 Checkpoint:** Left column fully functional, responsive on mobile/tablet, all clicks wired to mock data.
|
||||
|
||||
**Commit Messages:**
|
||||
```
|
||||
feat(dashboard-ui): add responsive pilot dashboard layout shell
|
||||
feat(dashboard-kpi): implement kpi cards daily summary and operations strip
|
||||
feat(active-jobs): implement status-driven list with progress and navigation
|
||||
test(dashboard-layout): add responsive and accessibility tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Analytics & Polish (6-8 Days)
|
||||
|
||||
### Deliverables
|
||||
- Trend charts (hours flown, hectares per day)
|
||||
- Performance indicators (XT Error, Altitude)
|
||||
- Date range controls
|
||||
- i18n for EN, PT, ES
|
||||
- Error and loading states
|
||||
|
||||
### Tasks
|
||||
|
||||
| Task | Owner | Days | Dependencies | Notes |
|
||||
|------|-------|------|--------------|-------|
|
||||
| **3.1** Integrate p-chart for hours flown (line chart) | Frontend | 1.0 | 1.1 | Chart.js 2.9.3, PrimeNG wrapper |
|
||||
| **3.2** Integrate p-chart for hectares per day (bar chart) | Frontend | 1.0 | 1.1 | Similar setup; add target line overlay |
|
||||
| **3.3** Build date range control (calendar picker) | Frontend | 1.0 | 3.1-3.2 | Default current week Mon-Sun; user can change |
|
||||
| **3.4** Implement XT Error indicator with thresholds | Frontend | 0.75 | 1.1 | Green <1m, Yellow 1-3m, Red >3m; color bar |
|
||||
| **3.5** Implement altitude indicator with thresholds | Frontend | 0.75 | 1.1 | Target ~3.7m, Good ±0.15m, Monitor ±0.46m, Poor beyond ±0.46m |
|
||||
| **3.6** Add no-data state for altitude (device dependency) | Frontend | 0.25 | 3.5 | Show placeholder if sprayHeight/radarAlt not available |
|
||||
| **3.7** Add loading skeletons for all sections | Frontend | 0.75 | 2.1-3.6 | PrimeNG skeleton or custom shimmer; per widget |
|
||||
| **3.8** Add empty state messages | Frontend | 0.5 | 3.7 | "No jobs assigned", "No data available", etc. |
|
||||
| **3.9** Implement API error handling & fallbacks | Frontend | 0.75 | 1.3 | Toast notifications, graceful degradation |
|
||||
| **3.10** Extract i18n strings (EN baseline) | Frontend | 0.5 | All sections | Update locale/en-Application.json, all $localize() calls |
|
||||
| **3.11** Generate PT and ES translations | Frontend | 1.0 | 3.10 | Run xliffmerge workflow; validate no hardcoded English |
|
||||
| **3.12** Test all chart responsiveness on mobile | Frontend | 0.5 | 3.1-3.3 | Charts stack/hide on small screens |
|
||||
| **3.13** Unit tests for indicators, error handling, i18n | Frontend | 0.75 | 3.1-3.11 | Mock API responses, locale switching |
|
||||
|
||||
**Phase 3 Checkpoint:** Right column fully functional, all i18n ready, error scenarios handled.
|
||||
|
||||
**Commit Messages:**
|
||||
```
|
||||
feat(trends): add weekly trend charts with date range controls
|
||||
feat(performance): add xt error and altitude indicators with threshold bands
|
||||
feat(dashboard-state): add loading empty and error states for dashboard widgets
|
||||
feat(i18n): localize pilot dashboard labels for en pt es
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: QA, Integration & Release Hardening (4-5 Days)
|
||||
|
||||
### Deliverables
|
||||
- Full end-to-end testing (mock and real backend if available)
|
||||
- Role and permission isolation verification
|
||||
- Cross-browser and device testing
|
||||
- Performance and bundle size checks
|
||||
- Release-ready build
|
||||
|
||||
### Tasks
|
||||
|
||||
| Task | Owner | Days | Dependencies | Notes |
|
||||
|------|-------|------|--------------|-------|
|
||||
| **4.1** Integration test: dashboard calls all 5 backend endpoints correctly | Frontend | 0.75 | 1.3 | Mock or real backend; verify request/response shapes |
|
||||
| **4.2** Role isolation test: non-pilot roles see disclaimer only | Frontend | 0.5 | 1.5 | Test ADMIN, APP, OFFICER, CLIENT, INSPECTOR roles |
|
||||
| **4.3** Responsive + mobile layout testing (Chrome, Safari, Firefox, Edge) | Frontend | 1.0 | All phases | Desktop, tablet, mobile; landscape/portrait |
|
||||
| **4.4** Performance audit (performance and bundle impact) | Frontend | 0.5 | All phases | Record findings and optimize only if needed |
|
||||
| **4.5** Regression testing: existing home for non-pilots unchanged | Frontend | 0.5 | 1.5 | Verify disclaimer still works; no side effects |
|
||||
| **4.6** E2E tests (user flow: login → dashboard → click job → detail) | Frontend | 0.75 | 4.1-4.5 | Protractor or Cypress if available |
|
||||
| **4.7** Lint, build, and final code review | Frontend | 0.5 | All phases | ng lint, ng build, tslint clean |
|
||||
| **4.8** Documentation: user guide for pilot dashboard features | Frontend | 0.5 | All phases | README section, feature overview, known limitations |
|
||||
| **4.9** Deployment dry-run and release notes | DevOps/Frontend | 0.5 | 4.7 | Build prod, verify localization (en/pt/es), changelog |
|
||||
|
||||
**Phase 4 Checkpoint:** Ready for staging and user acceptance testing.
|
||||
|
||||
**Commit Messages:**
|
||||
```
|
||||
test(dashboard): add e2e and integration tests
|
||||
test(dashboard): add role isolation and regression tests
|
||||
chore(dashboard): final ui polish and accessibility adjustments
|
||||
docs(dashboard): add pilot dashboard user guide and feature summary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Backend Parallel Track (NOT included in frontend days)
|
||||
|
||||
**If backend endpoints do NOT exist yet, backend team should start immediately:**
|
||||
|
||||
| Task | Owner | Days | Dependencies |
|
||||
|------|-------|------|--------------|
|
||||
| **B.1** Design and implement 5 dashboard endpoints | Backend | 3-4 | Spec from 1.6 |
|
||||
| **B.2** Add aggregation queries (KPI, trend, performance) | Backend | 2-3 | Database schema review |
|
||||
| **B.3** Implement pilot data isolation (filter by Job.operator = pilotId) | Backend | 1-2 | Auth middleware |
|
||||
| **B.4** Unit tests + load tests (concurrent dashboard calls) | Backend | 1-2 | B.1-B.3 |
|
||||
| **B.5** Integration with frontend (live API testing) | Backend/Frontend | 1-2 | Phase 4.1 |
|
||||
|
||||
**Backend Total:** 8-13 days (parallel to Phase 2-3)
|
||||
|
||||
---
|
||||
|
||||
## 📅 Suggested Sprint Schedule
|
||||
|
||||
### Week 1 (Mon Apr 28 - Fri May 2)
|
||||
- **Days 1-3:** Phase 1 (foundation, contracts, mock service)
|
||||
- **Days 4-5:** Phase 2 start (layout, KPI cards)
|
||||
- **PR 1 merged by EOW**
|
||||
|
||||
### Week 2 (Mon May 5 - Fri May 9)
|
||||
- **Days 1-3:** Phase 2 complete (active jobs panel, responsive polish)
|
||||
- **Days 4-5:** Phase 3 start (charts, date range)
|
||||
- **PR 2 merged by EOW**
|
||||
|
||||
### Week 3 (Mon May 12 - Fri May 16)
|
||||
- **Days 1-3:** Phase 3 continue (performance indicators, i18n)
|
||||
- **Days 4-5:** Phase 3 complete (error states, final tests)
|
||||
- **PR 3 merged by EOW**
|
||||
|
||||
### Week 4 (Mon May 19 - Fri May 23)
|
||||
- **Days 1-3:** Phase 4 (integration testing, E2E, role isolation)
|
||||
- **Days 4-5:** Phase 4 complete (perf audit, release prep)
|
||||
- **PR 4 merged by EOW**
|
||||
|
||||
### Week 5 (Mon May 26 - Wed May 28)
|
||||
- **Day 1:** Staging deployment, user acceptance testing
|
||||
- **Days 2-3:** Bug fixes, final release sign-off
|
||||
|
||||
---
|
||||
|
||||
## Daily Time Breakdown (Typical)
|
||||
|
||||
Assuming 8-hour work day:
|
||||
|
||||
- **Development:** 5-6 hours
|
||||
- **Testing (manual + automated):** 1-1.5 hours
|
||||
- **Code review + feedback incorporation:** 0.5-1 hour
|
||||
- **Documentation + commit messages:** 0.5 hour
|
||||
- **Sync meetings (standup, demos):** 0.5 hour
|
||||
|
||||
---
|
||||
|
||||
## Critical Path & Blockers
|
||||
|
||||
### Critical Path
|
||||
1. **Phase 1.6 (Backend spec)** must be done before backend team starts
|
||||
2. **Phase 1.3 (Service layer)** must exist before Phase 2 UI work (can use mocks)
|
||||
3. **Phase 2 (UI)** can proceed in parallel with backend if using mock data
|
||||
4. **Phase 4.1 (Integration)** requires backend endpoints live
|
||||
|
||||
### Known Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|-----------|
|
||||
| Backend endpoints not ready | Phase 4 blocked | Use mock service in Phase 2-3; swap endpoints at Phase 4 |
|
||||
| Status enum changes break existing jobs | High | Coordinate with backend; test all job statuses after enum change |
|
||||
| i18n extraction tool issues | Phase 3 delayed | Pre-test localization workflow in Phase 1 |
|
||||
| Chart.js version compatibility | Phase 3 blocked | Verify chart.js 2.9.3 works with PrimeNG 9.2.8 early in Phase 3.1 |
|
||||
| Mobile responsive issues late | Phase 4 delay | Test mobile early (end of Phase 2), not Phase 4 |
|
||||
| Job detail navigation broken | Phase 4 blocker | End-to-end test in Phase 2 with mock routing |
|
||||
|
||||
---
|
||||
|
||||
## Effort Distribution
|
||||
|
||||
```
|
||||
Phase 1: ████░░░░░░░░░░░░░░ (22% - Foundation)
|
||||
Phase 2: ███████░░░░░░░░░░░░ (30% - UI Core)
|
||||
Phase 3: ██████░░░░░░░░░░░░░ (28% - Analytics + Polish)
|
||||
Phase 4: ████░░░░░░░░░░░░░░░ (20% - QA + Release)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria (Definition of Done)
|
||||
|
||||
- [ ] All 12 steps implemented
|
||||
- [ ] All 5 dashboard API endpoints wired and tested
|
||||
- [ ] Pilot-only home working; non-pilots see original disclaimer
|
||||
- [ ] Active jobs panel scrollable, clickable, color-coded
|
||||
- [ ] Charts render with date range control
|
||||
- [ ] Performance indicators show correct thresholds
|
||||
- [ ] i18n works for EN, PT, ES
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Performance audit completed and documented
|
||||
- [ ] Mobile responsive (320px to 1920px)
|
||||
- [ ] Cross-browser tested (Chrome, Safari, Firefox, Edge)
|
||||
- [ ] All 4 PRs merged and passed review
|
||||
- [ ] Release notes and user guide complete
|
||||
|
||||
---
|
||||
|
||||
## Contingency Buffer
|
||||
|
||||
- **Unplanned blockers:** +2 days
|
||||
- **Backend integration issues:** +1-2 days
|
||||
- **UX revisions:** +1-2 days
|
||||
|
||||
**Total realistic completion:** 26-32 working days (5-6.5 weeks)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Mock Data:** Use throughout Phase 2-3 to avoid backend dependency blocking UI work.
|
||||
2. **Commit Frequency:** Daily commits, PR every 1-2 days.
|
||||
3. **Testing:** Continuous throughout all phases, not batched at end.
|
||||
4. **Documentation:** Update README and include known limitations.
|
||||
5. **Accessibility:** Test with keyboard nav, screen reader, high contrast mode.
|
||||
6. **Performance:** Keep watch on bundle size; lazy-load charts if needed.
|
||||
|
||||
---
|
||||
|
||||
Generated: April 28, 2026
|
||||
Last Updated: April 28, 2026
|
||||
506
Development/client/docs/PILOT_DASHBOARD_TASK_CHECKLIST.md
Normal file
506
Development/client/docs/PILOT_DASHBOARD_TASK_CHECKLIST.md
Normal file
@ -0,0 +1,506 @@
|
||||
# Pilot Analytics Dashboard - Task Checklist & Tracking
|
||||
|
||||
Use this checklist to track your daily progress. Update status as you move through each task.
|
||||
|
||||
## Source Files
|
||||
|
||||
This checklist is derived from the following source files:
|
||||
|
||||
- Primary: `Pilot_Dashboard_PO_Brief_v1.md` (v1.5)
|
||||
- Primary: `Pilot_Dashboard_Requirements_v1.md` (v1.5)
|
||||
- Supporting: `plan-pilotAnalyticsDashboard.prompt.md`
|
||||
- Supporting: `Pilot-Dashboard-UI-UX-Design-Specification.md`
|
||||
- Supporting visual reference: `Sample Pilot Dashboard.png` and the PyQt mockup
|
||||
|
||||
When source documents conflict, the v1.5 PO brief and v1.5 technical requirements take precedence.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation & Contracts (Days 1-6)
|
||||
|
||||
### Day 1: Extend JobStatus Enum
|
||||
|
||||
**Task 1.1** - Extend JobStatus enum + global constants
|
||||
- [ ] Add `COMPLETED = 4` to JobStatus enum in global.ts
|
||||
- [ ] Add `INVOICED = 5` to JobStatus enum in global.ts
|
||||
- [ ] Update JobStatuses map with new status labels
|
||||
- [ ] Update GC.selJobStatuses with new options
|
||||
- [ ] Add new status to jobListStatus constants
|
||||
- [ ] Update JobStatusPipe to handle new values
|
||||
- [ ] Lint and verify no breaking changes
|
||||
- [ ] **Commit:** `refactor(global): add completed and invoiced job statuses`
|
||||
|
||||
---
|
||||
|
||||
### Days 2-3: Dashboard Service Layer
|
||||
|
||||
**Task 1.2** - Design dashboard data contracts
|
||||
- [ ] Create new file: `src/app/domain/models/pilot-dashboard.model.ts`
|
||||
- [ ] Define `PilotKpiResponse` interface
|
||||
- [ ] Define `PilotSummaryResponse` interface (today vs yesterday)
|
||||
- [ ] Define `PilotOperationsResponse` interface
|
||||
- [ ] Define `PilotActiveJobsResponse` interface
|
||||
- [ ] Define `PilotTrendResponse` interface
|
||||
- [ ] Define `PilotPerformanceResponse` interface
|
||||
- [ ] Add detailed JSDoc comments to each
|
||||
|
||||
**Task 1.3** - Implement dashboard service
|
||||
- [ ] Create `src/app/domain/services/pilot-dashboard.service.ts`
|
||||
- [ ] Inject HttpClient
|
||||
- [ ] Implement `getKpi()` → GET /api/dashboard/pilot/kpi
|
||||
- [ ] Implement `getSummary()` → GET /api/dashboard/pilot/summary
|
||||
- [ ] Implement `getActiveJobs()` → GET /api/dashboard/pilot/active-jobs
|
||||
- [ ] Implement `getTrend(startDate, endDate)` → GET /api/dashboard/pilot/trend
|
||||
- [ ] Implement `getPerformance()` → GET /api/dashboard/pilot/performance
|
||||
- [ ] Add proper HttpParams for query strings
|
||||
- [ ] Add operators like `catchError` for error handling
|
||||
- [ ] **Commit:** `feat(dashboard): add pilot dashboard api service and response models`
|
||||
|
||||
**Task 1.4** - Add service unit tests
|
||||
- [ ] Create `src/app/domain/services/pilot-dashboard.service.spec.ts`
|
||||
- [ ] Mock HttpClient
|
||||
- [ ] Test each endpoint method calls correct URL
|
||||
- [ ] Test response type mapping
|
||||
- [ ] Test error handling
|
||||
- [ ] Verify tests pass: `ng test --include='*dashboard.service.spec.ts'`
|
||||
|
||||
**Task 1.5** - Setup mock data provider
|
||||
- [ ] Create `src/app/shared/mock/pilot-dashboard-mock.ts`
|
||||
- [ ] Export mock KPI data
|
||||
- [ ] Export mock summary data
|
||||
- [ ] Export mock active jobs (3-5 sample jobs)
|
||||
- [ ] Export mock trend data (7 days)
|
||||
- [ ] Export mock performance data
|
||||
- [ ] Create `PilotDashboardMockService extends PilotDashboardService` (optional)
|
||||
- [ ] Document how to use in development
|
||||
|
||||
---
|
||||
|
||||
### Day 4: Role-Based Home Branching
|
||||
|
||||
**Task 1.5** - Update dashboard component
|
||||
- [ ] Open `src/app/dashboard/dashboard.component.ts`
|
||||
- [ ] Inject `AuthService` and `PilotDashboardService`
|
||||
- [ ] Add `isPilot$: Observable<boolean>` property
|
||||
- [ ] Add `isPilot = this.authSvc.isPilotUser` getter
|
||||
- [ ] Add component initialization logic
|
||||
|
||||
**Task 1.5b** - Update dashboard template
|
||||
- [ ] Open `src/app/dashboard/dashboard.component.html`
|
||||
- [ ] Replace entire template with:
|
||||
```html
|
||||
<div class="ui-g">
|
||||
<ng-container *ngIf="authSvc.isPilotUser; then pilotDashboard; else disclaimerSection"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #pilotDashboard>
|
||||
<agm-pilot-dashboard></agm-pilot-dashboard>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #disclaimerSection>
|
||||
<!-- Keep existing disclaimer template -->
|
||||
</ng-template>
|
||||
```
|
||||
- [ ] Keep existing disclaimer template intact
|
||||
- [ ] **Commit:** `feat(home): add pilot-only dashboard branch with fallback disclaimer`
|
||||
|
||||
---
|
||||
|
||||
### Days 5-6: Backend Spec & Documentation
|
||||
|
||||
**Task 1.6** - Finalize backend API spec
|
||||
- [ ] Create `docs/PILOT_DASHBOARD_API_SPEC.md`
|
||||
- [ ] Document all 5 endpoint specs with:
|
||||
- [ ] Request path, method, query params
|
||||
- [ ] Request body (if POST)
|
||||
- [ ] Response shape (TypeScript interface)
|
||||
- [ ] Sample request/response JSON
|
||||
- [ ] Error cases (400, 401, 404, 500)
|
||||
- [ ] Pilot data isolation requirements (filter by Job.operator = pilotId)
|
||||
- [ ] Get sign-off from backend team
|
||||
- [ ] **Commit:** `docs(dashboard): specify pilot dashboard backend api contracts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core UI Delivery (Days 7-14)
|
||||
|
||||
### Day 7: Responsive Layout Shell
|
||||
|
||||
**Task 2.1** - Build responsive grid layout
|
||||
- [ ] Create new component: `src/app/dashboard/pilot-dashboard/pilot-dashboard.component.ts`
|
||||
- [ ] Create template: `src/app/dashboard/pilot-dashboard/pilot-dashboard.component.html`
|
||||
- [ ] Create styles: `src/app/dashboard/pilot-dashboard/pilot-dashboard.component.scss`
|
||||
- [ ] Import in DashboardComponent
|
||||
- [ ] Setup PrimeNG grid (p-grid, p-col):
|
||||
- [ ] Full-width KPI row
|
||||
- [ ] Two-column layout: left 65%, right 35%
|
||||
- [ ] Mobile: single column, stack all sections
|
||||
- [ ] Add placeholder divs for each section
|
||||
- [ ] Test responsive breakpoints (1920px, 1366px, 768px, 420px)
|
||||
- [ ] **Commit:** `feat(dashboard-ui): add responsive pilot dashboard layout shell`
|
||||
|
||||
---
|
||||
|
||||
### Days 8-9: KPI Cards & Summary Strips
|
||||
|
||||
**Task 2.2** - Create KPI card component
|
||||
- [ ] Create `src/app/dashboard/components/kpi-card/kpi-card.component.ts`
|
||||
- [ ] Add `@Input() icon`, `@Input() value`, `@Input() unit`, `@Input() historical`
|
||||
- [ ] Template: large value + mini historical lines
|
||||
- [ ] Styles: card, icon, responsive text sizing
|
||||
- [ ] No data loading yet (will wire to service later)
|
||||
|
||||
**Task 2.3** - Implement daily summary strip
|
||||
- [x] Create `src/app/dashboard/components/daily-summary/daily-summary.component.ts`
|
||||
- [x] Template: 5 metrics, each with trend arrow
|
||||
- [x] Green up arrow for improvement, red down for decline
|
||||
- [x] Styles: full-width dark green bar
|
||||
- [x] Calculate deltas (today vs yesterday percentages)
|
||||
|
||||
**Task 2.4** - Add operations today panel
|
||||
- [ ] Create `src/app/dashboard/components/operations-today/operations-today.component.ts`
|
||||
- [ ] Template: distance (km) + spray volume (L)
|
||||
- [ ] Styles: simple two-metric strip layout
|
||||
- [ ] Do not include flights today in Phase 1 because current data is not reliable enough
|
||||
- [ ] Keep data binding flexible for service calls
|
||||
|
||||
**Task 2.5** - Wire KPI + Summary to template
|
||||
- [ ] Add KPI cards to main template (4 cards in row)
|
||||
- [ ] Add daily summary below KPI
|
||||
- [ ] Add operations today below summary
|
||||
- [ ] Add mock data injection initially
|
||||
- [ ] **Commit:** `feat(dashboard-kpi): implement kpi cards daily summary and operations strip`
|
||||
|
||||
---
|
||||
|
||||
### Days 10-12: Active Jobs Panel
|
||||
|
||||
**Task 2.6** - Build active jobs panel component
|
||||
- [ ] Create `src/app/dashboard/components/active-jobs/active-jobs.component.ts`
|
||||
- [ ] Create job row component: `job-row.component.ts`
|
||||
- [ ] Template: scrollable list, each row shows:
|
||||
- [ ] Left color bar (blue/yellow/green)
|
||||
- [ ] Status badge
|
||||
- [ ] Aircraft tail + field name
|
||||
- [ ] Client name
|
||||
- [ ] Progress bar (visible for IN PROGRESS and COMPLETED only)
|
||||
- [ ] Ha sprayed / Ha total
|
||||
- [ ] Volume applied
|
||||
|
||||
**Task 2.7** - Implement status colors
|
||||
- [ ] Define CSS classes for status colors:
|
||||
- [ ] `.status-new` = blue
|
||||
- [ ] `.status-in-progress` = yellow
|
||||
- [ ] `.status-completed` = green
|
||||
- [ ] Add color to left edge bar
|
||||
- [ ] Add color to status badge
|
||||
- [ ] Add progress bar fill percentage
|
||||
|
||||
**Task 2.8** - Add row interactions
|
||||
- [ ] Click row → navigate to job detail route
|
||||
- [ ] Add "View All" link at bottom → /jobs
|
||||
- [ ] Add hover effects (slight shadow/highlight)
|
||||
- [ ] Make panel independently scrollable
|
||||
- [ ] Test with 10+ mock jobs
|
||||
|
||||
**Task 2.9** - Add loading and empty states
|
||||
- [ ] Show skeleton loaders while fetching
|
||||
- [ ] Show "No jobs assigned" when empty
|
||||
- [ ] Show error message on API failure
|
||||
- [ ] **Commit:** `feat(active-jobs): implement status-driven list with progress and navigation`
|
||||
|
||||
---
|
||||
|
||||
### Days 13-14: Responsive Testing & Accessibility
|
||||
|
||||
**Task 2.10** - Responsive testing
|
||||
- [ ] Test on mobile (420px width): stacks vertically
|
||||
- [ ] Test on tablet (768px width): 65/35 split adjusted
|
||||
- [ ] Test on desktop (1920px width): full layout
|
||||
- [ ] Test orientation changes (portrait ↔ landscape)
|
||||
- [ ] Verify text doesn't clip
|
||||
- [ ] Verify scrolling works on all sections
|
||||
|
||||
**Task 2.11** - Accessibility audit
|
||||
- [ ] Check ARIA labels on all interactive elements
|
||||
- [ ] Verify keyboard navigation (Tab, Enter)
|
||||
- [ ] Check color contrast ratios (WCAG AA)
|
||||
- [ ] Verify focus indicators visible
|
||||
- [ ] Test with screen reader (NVDA or JAWS)
|
||||
- [ ] Run Lighthouse accessibility audit
|
||||
- [ ] **Commit:** `test(dashboard-layout): add responsive and accessibility tests`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Analytics & Polish (Days 15-22)
|
||||
|
||||
### Days 15-16: Charts & Date Range Control
|
||||
|
||||
**Task 3.1** - Integrate hours flown chart
|
||||
- [ ] Create `src/app/dashboard/components/hours-chart/hours-chart.component.ts`
|
||||
- [ ] Use PrimeNG `p-chart` component
|
||||
- [ ] Chart type: line
|
||||
- [ ] X-axis: Mon-Sun labels
|
||||
- [ ] Y-axis: hours
|
||||
- [ ] Mock data: 7 data points
|
||||
|
||||
**Task 3.2** - Integrate hectares per day chart
|
||||
- [ ] Create `src/app/dashboard/components/hectares-chart/hectares-chart.component.ts`
|
||||
- [ ] Chart type: bar
|
||||
- [ ] X-axis: Mon-Sun labels
|
||||
- [ ] Y-axis: hectares
|
||||
- [ ] Add target line overlay (from dashboard spec)
|
||||
|
||||
**Task 3.3** - Build date range control
|
||||
- [ ] Add PrimeNG Calendar (p-calendar) component above charts
|
||||
- [ ] Default to current week (Mon-Sun)
|
||||
- [ ] Allow user to select custom start/end dates
|
||||
- [ ] Max range: 90 days
|
||||
- [ ] Emit event on date change
|
||||
- [ ] Re-fetch chart data on date change
|
||||
- [ ] **Commit:** `feat(trends): add weekly trend charts with date range controls`
|
||||
|
||||
---
|
||||
|
||||
### Days 17-18: Performance Indicators
|
||||
|
||||
**Task 3.4** - Implement XT Error indicator
|
||||
- [ ] Create `src/app/dashboard/components/xt-error-indicator/xt-error-indicator.component.ts`
|
||||
- [ ] Display current XT Error value in meters
|
||||
- [ ] Horizontal color bar:
|
||||
- [ ] Green: < 1.0 m (Good)
|
||||
- [ ] Yellow: 1.0 - 3.0 m (Monitor)
|
||||
- [ ] Red: > 3.0 m (Poor)
|
||||
- [ ] Show threshold labels below bar
|
||||
|
||||
|
||||
**Task 3.5** - Implement altitude indicator
|
||||
- [ ] Create `src/app/dashboard/components/altitude-indicator/altitude-indicator.component.ts`
|
||||
- [ ] Display current altitude in meters and feet
|
||||
- [ ] Horizontal color bar:
|
||||
- [ ] Green: within ±0.15 m of 3.7 m target (Good)
|
||||
- [ ] Yellow: within ±0.46 m of target (Monitor)
|
||||
- [ ] Red: beyond ±0.46 m (Poor)
|
||||
- [ ] Show threshold labels below bar
|
||||
- [ ] Show altitude source (sprayHeight / radarAlt / GPS)
|
||||
|
||||
**Task 3.6** - Add no-data state
|
||||
- [ ] If no altitude data available: show placeholder
|
||||
- [ ] Message: "Altitude data not available (requires Flight Master or radar)"
|
||||
- [ ] Don't crash if data missing
|
||||
- [ ] **Commit:** `feat(performance): add xt error and altitude indicators with threshold bands`
|
||||
|
||||
---
|
||||
|
||||
### Days 19-20: Loading, Empty, & Error States
|
||||
|
||||
**Task 3.7** - Add loading skeletons
|
||||
- [ ] Add skeleton loaders for:
|
||||
- [ ] KPI cards
|
||||
- [ ] Daily summary
|
||||
- [ ] Active jobs (3-row skeleton)
|
||||
- [ ] Charts (chart-shaped skeleton)
|
||||
- [ ] Indicators
|
||||
- [ ] Use consistent skeleton styling
|
||||
- [ ] Show during initial load and refresh
|
||||
|
||||
**Task 3.8** - Add empty states
|
||||
- [ ] "No jobs assigned" for active jobs section
|
||||
- [ ] "No data available" for charts with date range
|
||||
- [ ] "No recent flights" for performance indicators
|
||||
- [ ] Add friendly icons + messages
|
||||
|
||||
**Task 3.9** - Implement error handling
|
||||
- [ ] Catch HTTP errors from all 5 endpoints
|
||||
- [ ] Show toast notification on error
|
||||
- [ ] Retry button for failed requests
|
||||
- [ ] Fallback to empty state or mock data
|
||||
- [ ] Log errors to console for debugging
|
||||
- [ ] **Commit:** `feat(dashboard-state): add loading empty and error states for dashboard widgets`
|
||||
|
||||
---
|
||||
|
||||
### Days 21-22: i18n Integration
|
||||
|
||||
**Task 3.10** - Extract English strings
|
||||
- [ ] Add $localize() calls to all new labels:
|
||||
```typescript
|
||||
$localize`:@@assignedJobs:Assigned Jobs`
|
||||
$localize`:@@hectaresSprayed:Hectares Sprayed Today`
|
||||
// ... (all new strings)
|
||||
```
|
||||
- [ ] Update `src/locale/en-Application.json` with new keys
|
||||
- [ ] Run extraction: `npm run i18n-extract`
|
||||
- [ ] Verify messages.xlf has all new strings
|
||||
|
||||
**Task 3.11** - Generate PT & ES translations
|
||||
- [ ] Run merge: `npm run i18n-merge`
|
||||
- [ ] Translate new strings in PT (messages.pt.xlf)
|
||||
- [ ] Translate new strings in ES (messages.es.xlf)
|
||||
- [ ] Verify no hardcoded English in templates
|
||||
- [ ] Test locale switching (browser locale or route param)
|
||||
|
||||
**Task 3.12** - Test i18n functionality
|
||||
- [ ] Test EN version: all labels in English
|
||||
- [ ] Test PT version: all labels in Portuguese
|
||||
- [ ] Test ES version: all labels in Spanish
|
||||
- [ ] Verify numbers/dates format per locale
|
||||
- [ ] **Commit:** `feat(i18n): localize pilot dashboard labels for en pt es`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: QA, Integration & Release (Days 23-27)
|
||||
|
||||
### Days 23-24: Integration & End-to-End Testing
|
||||
|
||||
**Task 4.1** - Integration with backend (if available)
|
||||
- [ ] Swap mock service with real PilotDashboardService
|
||||
- [ ] Verify all 5 endpoints respond correctly
|
||||
- [ ] Check response shapes match interfaces
|
||||
- [ ] Verify data displays in UI without errors
|
||||
- [ ] Test pagination/limits (if applicable)
|
||||
|
||||
**Task 4.2** - Role isolation testing
|
||||
- [ ] Login as PILOT → see dashboard
|
||||
- [ ] Login as APP → see disclaimer
|
||||
- [ ] Login as ADMIN → see disclaimer
|
||||
- [ ] Login as OFFICER → see disclaimer
|
||||
- [ ] Login as CLIENT → see disclaimer
|
||||
- [ ] Verify no console errors
|
||||
|
||||
**Task 4.3** - E2E user flow testing
|
||||
- [ ] Login as pilot
|
||||
- [ ] Dashboard loads with data
|
||||
- [ ] Click KPI card (navigate to job or open detail view)
|
||||
- [ ] Click job row → opens job detail page
|
||||
- [ ] Click "View All" → navigates to /jobs
|
||||
- [ ] Change date range → charts update
|
||||
- [ ] **Commit:** `test(dashboard): add e2e and integration tests`
|
||||
|
||||
---
|
||||
|
||||
### Days 25-26: Cross-Browser & Performance Testing
|
||||
|
||||
**Task 4.4** - Cross-browser testing
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Edge (latest)
|
||||
- [ ] Mobile browsers (iOS Safari, Chrome Mobile)
|
||||
- [ ] Verify layout, fonts, colors consistent
|
||||
|
||||
**Task 4.5** - Mobile device testing
|
||||
- [ ] iPhone 12/13
|
||||
- [ ] iPad
|
||||
- [ ] Android phone (Samsung)
|
||||
- [ ] Android tablet
|
||||
- [ ] Test touch interactions, scroll, tap
|
||||
- [ ] Verify text readable, buttons tappable
|
||||
|
||||
**Task 4.6** - Performance audit
|
||||
- [ ] Run performance audit and record findings
|
||||
- [ ] Check bundle size impact
|
||||
- [ ] Analyze chart rendering performance (no lag)
|
||||
- [ ] Check for memory leaks (DevTools profiler)
|
||||
- [ ] Optimize if needed (lazy-load charts, virtual scroll)
|
||||
|
||||
**Task 4.7** - Regression testing
|
||||
- [ ] Verify existing home page still works for non-pilots
|
||||
- [ ] Verify existing jobs page unaffected
|
||||
- [ ] Verify existing job detail page unaffected
|
||||
- [ ] Run full test suite: `ng test`
|
||||
- [ ] Run linter: `ng lint`
|
||||
- [ ] **Commit:** `test(dashboard): add role isolation and regression tests`
|
||||
|
||||
---
|
||||
|
||||
### Day 27: Documentation & Release Prep
|
||||
|
||||
**Task 4.8** - Documentation
|
||||
- [ ] Update README.md with feature section
|
||||
- [ ] Document known limitations
|
||||
- [ ] Add screenshots to feature doc
|
||||
- [ ] Create user guide (pilot-facing)
|
||||
- [ ] Add architecture notes for future maintainers
|
||||
|
||||
**Task 4.9** - Build & release preparation
|
||||
- [ ] Build production: `ng build --prod --localize`
|
||||
- [ ] Verify build succeeds for all locales
|
||||
- [ ] Test build output in static server
|
||||
- [ ] Create CHANGELOG entry
|
||||
- [ ] Create release notes (features, fixes, known issues)
|
||||
- [ ] **Commit:** `chore(dashboard): final ui polish and accessibility adjustments`
|
||||
- [ ] **Commit:** `docs(dashboard): add pilot dashboard user guide and feature summary`
|
||||
|
||||
---
|
||||
|
||||
## Summary Progress Tracker
|
||||
|
||||
| Phase | Status | Days Actual | Notes |
|
||||
|-------|--------|------------|-------|
|
||||
| Phase 1 | Not Started | — | Foundation & contracts |
|
||||
| Phase 2 | Not Started | — | Core UI (KPI, summary, active jobs) |
|
||||
| Phase 3 | Not Started | — | Analytics & i18n |
|
||||
| Phase 4 | Not Started | — | QA & release |
|
||||
|
||||
---
|
||||
|
||||
## Daily Standup Template
|
||||
|
||||
Use this daily to track progress:
|
||||
|
||||
```
|
||||
### Day X (Date)
|
||||
|
||||
**Completed Today:**
|
||||
- Task 1.1: Extended JobStatus enum
|
||||
- Task 1.2: Designed dashboard models
|
||||
|
||||
**In Progress:**
|
||||
- Task 1.3: Implementing service layer (50% done)
|
||||
|
||||
**Blocked:**
|
||||
- None
|
||||
|
||||
**Next Day Plan:**
|
||||
- Complete Task 1.3 service implementation
|
||||
- Start unit tests for service
|
||||
- Setup mock data provider
|
||||
|
||||
**Notes:**
|
||||
- Chart.js 2.9.3 tested OK with PrimeNG
|
||||
- XT thresholds and altitude bands aligned to v1.5 requirement docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
npm start
|
||||
|
||||
# Run tests
|
||||
ng test
|
||||
ng test --include='*dashboard*.spec.ts' # Dashboard tests only
|
||||
|
||||
# Linting
|
||||
ng lint
|
||||
|
||||
# Build production
|
||||
ng build --prod --localize
|
||||
|
||||
# i18n workflow
|
||||
npm run sync-i18n # Extract + merge (macOS/Linux)
|
||||
npm run sync-i18n-w # Extract + merge (Windows)
|
||||
|
||||
# Generate coverage report
|
||||
ng test --code-coverage
|
||||
|
||||
# Bundle analysis
|
||||
npm run bundle-report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Last Updated: April 28, 2026
|
||||
@ -16,6 +16,8 @@ import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { ChartModule } from 'primeng-lts/chart';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
@ -25,9 +27,20 @@ import { AppTopbarComponent } from './app.topbar.component';
|
||||
import { AppInlineProfileComponent } from './app.profile.component';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { PilotDashboardComponent } from './dashboard/pilot-dashboard/pilot-dashboard.component';
|
||||
import { KpiCardComponent } from './dashboard/components/kpi-card/kpi-card.component';
|
||||
import { ReportComponent } from './report.component';
|
||||
import { PageNotFoundComponent } from './page-not-found.component';
|
||||
|
||||
import { SummaryStripComponent } from './dashboard/components/summary-strip/summary-strip.component';
|
||||
import { OperationsTodayComponent } from './dashboard/components/operations-today/operations-today.component';
|
||||
import { ActiveJobsComponent } from './dashboard/components/active-jobs/active-jobs.component';
|
||||
import { HoursChartComponent } from './dashboard/components/hours-chart/hours-chart.component';
|
||||
import { HectaresChartComponent } from './dashboard/components/hectares-chart/hectares-chart.component';
|
||||
import { XtErrorIndicatorComponent } from './dashboard/components/xt-error-indicator/xt-error-indicator.component';
|
||||
import { AltitudeIndicatorComponent } from './dashboard/components/altitude-indicator/altitude-indicator.component';
|
||||
import { BandBarComponent } from './dashboard/components/band-bar/band-bar.component';
|
||||
|
||||
import { StoreModule, ActionsSubject } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { AppDispatcher } from './app-actions';
|
||||
@ -87,7 +100,7 @@ export function translationsFactory(locale: string) {
|
||||
imports: [
|
||||
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
||||
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, ChartModule, InputSwitchModule, AppSharedModule,
|
||||
// The store that defines our app state
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers,
|
||||
@ -109,6 +122,16 @@ export function translationsFactory(locale: string) {
|
||||
MapEditBaseComp,
|
||||
PageNotFoundComponent,
|
||||
DashboardComponent,
|
||||
PilotDashboardComponent,
|
||||
KpiCardComponent,
|
||||
SummaryStripComponent,
|
||||
OperationsTodayComponent,
|
||||
ActiveJobsComponent,
|
||||
HoursChartComponent,
|
||||
HectaresChartComponent,
|
||||
XtErrorIndicatorComponent,
|
||||
AltitudeIndicatorComponent,
|
||||
BandBarComponent,
|
||||
AppComponent,
|
||||
AppMainComponent,
|
||||
AppMenuComponent,
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
<div class="active-jobs-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title" i18n="Active jobs panel heading@@pilotActiveJobsTitle">Active Jobs</h2>
|
||||
<a class="view-all-link" (click)="onViewAllClick()" i18n="View all jobs link@@pilotViewAllJobs">View All</a>
|
||||
</div>
|
||||
|
||||
<div class="jobs-list" *ngIf="isLoading">
|
||||
<div class="skeleton-row" *ngFor="let s of skeletonRows">
|
||||
<div class="skeleton-bar"></div>
|
||||
<div class="skeleton-content">
|
||||
<div class="skeleton-line wide"></div>
|
||||
<div class="skeleton-line narrow"></div>
|
||||
<div class="skeleton-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-message error-state" *ngIf="!isLoading && hasError">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span i18n="Active jobs error message@@pilotActiveJobsError">Failed to load jobs. Please try again.</span>
|
||||
</div>
|
||||
|
||||
<div class="state-message empty-state" *ngIf="!isLoading && !hasError && jobs.length === 0">
|
||||
<i class="pi pi-briefcase"></i>
|
||||
<span i18n="Active jobs empty state@@pilotActiveJobsEmpty">No jobs assigned</span>
|
||||
</div>
|
||||
|
||||
<div class="jobs-list" *ngIf="!isLoading && !hasError && jobs.length > 0">
|
||||
<div
|
||||
*ngFor="let job of jobs"
|
||||
class="job-row"
|
||||
[class.status-new]="job.status < JobStatus.SPRAYED"
|
||||
[class.status-in-progress]="job.status === JobStatus.SPRAYED"
|
||||
[class.status-completed]="job.status >= JobStatus.COMPLETED"
|
||||
(click)="onJobRowClick(job)"
|
||||
>
|
||||
<div class="status-bar"></div>
|
||||
|
||||
<div class="job-row-content">
|
||||
<div class="job-row-top">
|
||||
<div class="job-info">
|
||||
<span class="job-title">
|
||||
<ng-container *ngIf="job.aircraftReg">{{ job.aircraftReg }} — </ng-container>{{ job.name }}
|
||||
</span>
|
||||
<span class="job-client" i18n="Job client prefix@@pilotJobClientPrefix">Client: {{ job.clientName }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="status-badge"
|
||||
[class.badge-new]="job.status < JobStatus.SPRAYED"
|
||||
[class.badge-in-progress]="job.status === JobStatus.SPRAYED"
|
||||
[class.badge-completed]="job.status >= JobStatus.COMPLETED"
|
||||
>{{ job.displayStatus }}</span>
|
||||
</div>
|
||||
|
||||
<div class="job-row-bottom" *ngIf="showProgressBar(job); else notStarted">
|
||||
<div class="progress-metrics-row">
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill" [style.width.%]="job.progressPct"></div>
|
||||
</div>
|
||||
<div class="job-metrics">
|
||||
<span class="metric"><i class="pi pi-send"></i> {{ displayArea(job.haSprayed) | number:'1.0-0' }} / {{ displayArea(job.haTotal) | number:'1.0-0' }} {{ areaUnitLabel }}</span>
|
||||
<span class="metrics-sep">|</span>
|
||||
<span class="metric"><i class="pi pi-tint"></i> {{ displayVolume(job.volumeAppliedLiters) | number:'1.0-0' }} {{ volumeUnitLabel }} applied</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #notStarted>
|
||||
<div class="job-row-bottom not-started">
|
||||
<span class="not-started-label"><i class="pi pi-circle-off"></i> <ng-container i18n="Not started job label@@pilotJobNotStarted">Not started</ng-container></span>
|
||||
<div class="job-metrics">
|
||||
<span class="metric">0 / {{ displayArea(job.haTotal) | number:'1.0-0' }} {{ areaUnitLabel }}</span>
|
||||
<span class="metrics-sep">|</span>
|
||||
<span class="metric">0 {{ volumeUnitLabel }} applied</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,343 @@
|
||||
.active-jobs-panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #1a211c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
font-size: 0.95rem;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty and error states
|
||||
.state-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1rem;
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.empty-state {
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
&.error-state {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton loaders
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
border: 1px solid #eef0f2;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||
|
||||
.skeleton-bar {
|
||||
width: 6px;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
padding: 1.2rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.skeleton-line {
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
height: 12px;
|
||||
|
||||
&.wide { width: 55%; }
|
||||
&.narrow { width: 30%; }
|
||||
}
|
||||
|
||||
.skeleton-progress {
|
||||
background: #e0e0e0;
|
||||
border-radius: 6px;
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
border: 1px solid #eef0f2;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px 0 rgba(44, 62, 80, 0.07);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, background 0.15s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: #f6faf7;
|
||||
box-shadow: 0 3px 10px 0 rgba(44, 62, 80, 0.12);
|
||||
}
|
||||
|
||||
// Left status color bar
|
||||
.status-bar {
|
||||
width: 6px;
|
||||
border-radius: 12px 0 0 12px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
&.status-new .status-bar { background: #1976d2; }
|
||||
&.status-in-progress .status-bar { background: #f9a825; }
|
||||
&.status-completed .status-bar { background: #2e7d32; }
|
||||
|
||||
&.status-new .status-arrow { color: #1976d2; }
|
||||
&.status-in-progress .status-arrow { color: #f9a825; }
|
||||
&.status-completed .status-arrow { color: #2e7d32; }
|
||||
|
||||
.job-row-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem 1.2rem 1.5rem 1.4rem;
|
||||
|
||||
.job-row-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.job-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.job-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1a211c;
|
||||
|
||||
.status-arrow {
|
||||
margin-right: 0.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.job-client {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7680;
|
||||
margin-top: 0.1rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 1rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
|
||||
&.badge-new {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
&.badge-in-progress {
|
||||
background: #f9a825;
|
||||
}
|
||||
|
||||
&.badge-completed {
|
||||
background: #2e7d32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.job-row-bottom {
|
||||
.progress-metrics-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.progress-bar-wrap {
|
||||
flex: 1 1 auto;
|
||||
background: #e0e0e0;
|
||||
border-radius: 6px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #66bb6a, #2e7d32);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.job-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
|
||||
.metric {
|
||||
font-size: 0.85rem;
|
||||
color: #1a211c;
|
||||
font-weight: 600;
|
||||
|
||||
i {
|
||||
margin-right: 0.2rem;
|
||||
color: #2e7d32;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-sep {
|
||||
color: #bbb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.not-started {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.not-started-label {
|
||||
font-size: 0.85rem;
|
||||
color: #9e9e9e;
|
||||
font-style: italic;
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.job-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
|
||||
.metric {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7680;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metrics-sep {
|
||||
color: #bbb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet ≤1024px
|
||||
@media (max-width: 1024px) {
|
||||
.active-jobs-panel {
|
||||
.job-row .job-row-content { padding: 1.25rem 1rem 1.25rem 1.1rem; }
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile ≤768px
|
||||
@media (max-width: 768px) {
|
||||
.active-jobs-panel {
|
||||
padding: 1.25rem 1.25rem;
|
||||
border-radius: 16px;
|
||||
|
||||
.panel-header {
|
||||
.panel-title { font-size: 0.9rem; }
|
||||
.view-all-link { font-size: 0.85rem; }
|
||||
}
|
||||
|
||||
.job-row .job-row-content {
|
||||
padding: 1rem 0.85rem 1rem 1rem;
|
||||
|
||||
.job-row-top {
|
||||
.job-info .job-title { font-size: 0.9rem; }
|
||||
.job-info .job-client { font-size: 0.78rem; }
|
||||
.status-badge { font-size: 0.72rem; padding: 0.15rem 0.55rem; }
|
||||
}
|
||||
|
||||
.job-row-bottom .progress-metrics-row {
|
||||
gap: 0.6rem;
|
||||
.job-metrics .metric { font-size: 0.78rem; }
|
||||
.job-metrics .metrics-sep { font-size: 0.78rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small mobile ≤480px
|
||||
@media (max-width: 480px) {
|
||||
.active-jobs-panel {
|
||||
padding: 1rem;
|
||||
|
||||
.job-row .job-row-content {
|
||||
padding: 0.875rem 0.75rem 0.875rem 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PilotActiveJob } from '@app/domain/models/pilot-dashboard.model';
|
||||
import { JobStatus } from '@app/shared/global';
|
||||
import { UnitUtils } from '@app/shared/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-active-jobs',
|
||||
templateUrl: './active-jobs.component.html',
|
||||
styleUrls: ['./active-jobs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ActiveJobsComponent {
|
||||
@Input() jobs: PilotActiveJob[] = [];
|
||||
@Input() isLoading = false;
|
||||
@Input() hasError = false;
|
||||
@Input() isUS = false;
|
||||
|
||||
get areaUnitLabel(): string {
|
||||
return UnitUtils.areaUnitLabel(this.isUS);
|
||||
}
|
||||
|
||||
get volumeUnitLabel(): string {
|
||||
return UnitUtils.volumeUnitLabel(this.isUS);
|
||||
}
|
||||
|
||||
displayArea(ha: number): number {
|
||||
return UnitUtils.haToArea(ha, this.isUS);
|
||||
}
|
||||
|
||||
displayVolume(liters: number): number {
|
||||
return UnitUtils.litersToVolume(liters, this.isUS);
|
||||
}
|
||||
|
||||
readonly JobStatus = JobStatus;
|
||||
readonly skeletonRows = Array(4);
|
||||
|
||||
constructor(private router: Router) {}
|
||||
|
||||
showProgressBar(job: PilotActiveJob): boolean {
|
||||
return job.haSprayed > 0 || job.status === JobStatus.COMPLETED;
|
||||
}
|
||||
|
||||
onJobRowClick(job: PilotActiveJob): void {
|
||||
this.router.navigate(['/jobs', job.jobId]);
|
||||
}
|
||||
|
||||
onViewAllClick(): void {
|
||||
this.router.navigate(['/jobs']);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
<div class="alt-indicator-card">
|
||||
|
||||
<div class="card-header">
|
||||
<h3 class="card-title" i18n="Altitude indicator card title@@pilotAltTitle">Average Altitude Spraying</h3>
|
||||
</div>
|
||||
|
||||
<div class="state-message" *ngIf="isLoading">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</div>
|
||||
|
||||
<div class="state-message error-state" *ngIf="!isLoading && hasError">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span i18n="Altitude load failure@@pilotAltLoadFail">Failed to load performance data.</span>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isLoading && !hasError">
|
||||
|
||||
<ng-container *ngIf="hasData && value !== null; else noDataBody">
|
||||
|
||||
<div class="value-row">
|
||||
<span class="value-number">{{ displayValue | number:'1.1-1' }}</span>
|
||||
<span class="value-unit">{{ displayUnit }}</span>
|
||||
</div>
|
||||
|
||||
<div class="band-badge-row">
|
||||
<span class="band-badge" [ngClass]="'badge-' + band">{{ bandLabel }}</span>
|
||||
</div>
|
||||
|
||||
<agm-band-bar [bar]="bar"></agm-band-bar>
|
||||
|
||||
<div class="threshold-labels">
|
||||
<span class="tl-label tl-start">0</span>
|
||||
<span class="tl-label tl-mid" [style.left.%]="bar.goodPct">{{ displayGoodDelta | number:'1.0-1' }} {{ displayUnit }}</span>
|
||||
<span class="tl-label tl-mid" [style.left.%]="bar.monitorPct">{{ displayMonitorDelta | number:'1.0-1' }} {{ displayUnit }}</span>
|
||||
</div>
|
||||
|
||||
<div class="drift-line" [ngClass]="'drift-' + band" *ngIf="driftLabel">
|
||||
{{ driftLabel }}
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-item" i18n="Altitude legend target@@pilotAltLegendTarget">Target ~{{ displayTarget | number:'1.0-0' }} {{ displayUnit }}</span>
|
||||
<span class="legend-sep">|</span>
|
||||
<span class="legend-item" i18n="Altitude legend ideal@@pilotAltLegendIdeal">±{{ displayGoodDelta | number:'1.0-1' }} {{ displayUnit }} ideal</span>
|
||||
<span class="legend-sep">|</span>
|
||||
<span class="legend-item" i18n="Altitude legend high risk@@pilotAltLegendHighRisk">> ±{{ displayMonitorDelta | number:'1.0-1' }} {{ displayUnit }} high risk</span>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noDataBody>
|
||||
<div class="no-data-body">
|
||||
<i class="pi pi-chart-line no-data-icon"></i>
|
||||
<span i18n="Altitude no data message@@pilotAltNoData">Altitude data not available</span>
|
||||
<span class="no-data-hint" i18n="Altitude no data hint@@pilotAltNoDataHint">(requires Flight Master or radar)</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,43 @@
|
||||
@import '../../styles/indicator-card';
|
||||
|
||||
:host { display: block; }
|
||||
|
||||
.alt-indicator-card {
|
||||
@include indicator-card($badge-row-margin: 0.5rem, $value-row-margin: 0.5rem);
|
||||
|
||||
.drift-line {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&.drift-good { color: #2e7d32; }
|
||||
&.drift-monitor { color: #f57f17; }
|
||||
&.drift-poor { color: #c62828; }
|
||||
}
|
||||
|
||||
.no-data-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
color: #9e9e9e;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 2rem;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.no-data-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { PilotAltitudeThreshold } from '../../../domain/models/pilot-dashboard.model';
|
||||
import { IndicatorBand, BarState, classifyBand, getBandLabel, computeBarState } from '../../utils/indicator-band.utils';
|
||||
import { UnitUtils } from '../../../shared/utils';
|
||||
|
||||
export type AltSource = 'sprayHeight' | 'radarAlt' | 'gpsAlt' | 'none';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-altitude-indicator',
|
||||
templateUrl: './altitude-indicator.component.html',
|
||||
styleUrls: ['./altitude-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AltitudeIndicatorComponent {
|
||||
@Input() value: number | null = null;
|
||||
@Input() threshold: PilotAltitudeThreshold = { target: 3.658, goodDelta: 0.152, monitorDelta: 0.457 };
|
||||
@Input() source: AltSource = 'none';
|
||||
@Input() hasData = false;
|
||||
@Input() sampleSize = 0;
|
||||
@Input() isUS = false;
|
||||
@Input() isLoading = false;
|
||||
@Input() hasError = false;
|
||||
|
||||
get displayUnit(): string {
|
||||
return UnitUtils.lengthUnitLabel(this.isUS);
|
||||
}
|
||||
|
||||
get displayValue(): number | null {
|
||||
return this.value !== null ? (this.isUS ? this.valueFt : this.value) : null;
|
||||
}
|
||||
|
||||
get displayTarget(): number {
|
||||
return this.isUS ? this.targetFt : this.threshold.target;
|
||||
}
|
||||
|
||||
get displayGoodDelta(): number {
|
||||
return this.isUS ? this.goodDeltaFt : this.threshold.goodDelta;
|
||||
}
|
||||
|
||||
get displayMonitorDelta(): number {
|
||||
return this.isUS ? this.monitorDeltaFt : this.threshold.monitorDelta;
|
||||
}
|
||||
|
||||
private get valueFt(): number | null {
|
||||
return this.value !== null ? UnitUtils.mToFt(this.value) : null;
|
||||
}
|
||||
|
||||
private get targetFt(): number {
|
||||
return UnitUtils.mToFt(this.threshold.target);
|
||||
}
|
||||
|
||||
private get goodDeltaFt(): number {
|
||||
return UnitUtils.mToFt(this.threshold.goodDelta);
|
||||
}
|
||||
|
||||
private get monitorDeltaFt(): number {
|
||||
return UnitUtils.mToFt(this.threshold.monitorDelta);
|
||||
}
|
||||
|
||||
/** Signed drift in ft (positive = above target). */
|
||||
private get driftFt(): number | null {
|
||||
return this.value !== null ? UnitUtils.mToFt(this.value - this.threshold.target) : null;
|
||||
}
|
||||
|
||||
/** Absolute deviation from target in metres (used for band + bar). */
|
||||
private get deviationM(): number {
|
||||
return this.value !== null ? Math.abs(this.value - this.threshold.target) : 0;
|
||||
}
|
||||
|
||||
get band(): IndicatorBand {
|
||||
return classifyBand(this.deviationM, this.threshold.goodDelta, this.threshold.monitorDelta);
|
||||
}
|
||||
|
||||
get bandLabel(): string {
|
||||
return getBandLabel(this.band);
|
||||
}
|
||||
|
||||
get driftLabel(): string {
|
||||
const driftDisplay = this.isUS ? this.driftFt : (this.value !== null ? this.value - this.threshold.target : null);
|
||||
if (driftDisplay === null) { return ''; }
|
||||
const abs = Math.abs(driftDisplay);
|
||||
if (abs < 0.05) { return $localize`:Altitude on target status@@driftOnTarget:On target`; }
|
||||
const unit = this.displayUnit;
|
||||
const dir = driftDisplay < 0 ? '\u2193' : '\u2191';
|
||||
const dirWord = driftDisplay < 0
|
||||
? $localize`:Altitude below target@@driftDirectionBelow:below`
|
||||
: $localize`:Altitude above target@@driftDirectionAbove:above`;
|
||||
return `${dir} ${abs.toFixed(1)} ${unit} ${dirWord} target (${Math.round(this.displayTarget)} ${unit})`;
|
||||
}
|
||||
|
||||
get sourceLabel(): string {
|
||||
switch (this.source) {
|
||||
case 'sprayHeight': return $localize`:Altitude source spray height@@altSourceSprayHeight:Spray Height (Flight Master)`;
|
||||
case 'radarAlt': return $localize`:Altitude source radar@@altSourceRadar:Radar Altimeter (AGL)`;
|
||||
case 'gpsAlt': return $localize`:Altitude source GPS@@altSourceGps:GPS Altitude (MSL)`;
|
||||
default: return $localize`:Altitude source unknown@@altSourceUnknown:Unknown`;
|
||||
}
|
||||
}
|
||||
|
||||
get bar(): BarState {
|
||||
return computeBarState(this.threshold.goodDelta, this.threshold.monitorDelta, this.deviationM);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
<div class="bar-container">
|
||||
<div class="band-bar">
|
||||
<div class="band band-green" [style.width.%]="bar.bandWidths.green"></div>
|
||||
<div class="band band-yellow" [style.width.%]="bar.bandWidths.yellow"></div>
|
||||
<div class="band band-red" [style.width.%]="bar.bandWidths.red"></div>
|
||||
</div>
|
||||
<div class="marker-tick" [style.left.%]="bar.markerPct"></div>
|
||||
</div>
|
||||
@ -0,0 +1,33 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.band-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
|
||||
.band {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
|
||||
&.band-green { background: #43a047; }
|
||||
&.band-yellow { background: #fdd835; }
|
||||
&.band-red { background: #e53935; }
|
||||
}
|
||||
}
|
||||
|
||||
.marker-tick {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 3px;
|
||||
background: #1a211c;
|
||||
border-radius: 2px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { BarState } from '../../utils/indicator-band.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-band-bar',
|
||||
templateUrl: './band-bar.component.html',
|
||||
styleUrls: ['./band-bar.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BandBarComponent {
|
||||
@Input() bar: BarState;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<div class="hectares-chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{ areaLabel }}</h3>
|
||||
</div>
|
||||
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
||||
<p-chart id="hectaresBarChart" type="bar" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
|
||||
<div class="no-data-overlay" *ngIf="!hasData">
|
||||
<i class="pi pi-calendar-times"></i>
|
||||
<span i18n="Hectares chart no data@@pilotHectaresChartNoData">No spray activity for this period</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-loading" *ngIf="isLoading">
|
||||
<p-progressSpinner strokeWidth="3" [style]="{width: '40px', height: '40px'}"></p-progressSpinner>
|
||||
</div>
|
||||
<div class="chart-state-message" *ngIf="!isLoading && hasError">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span i18n="Hectares chart error@@errorLoadingHectaresChart">Failed to load chart data</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,60 @@
|
||||
.hectares-chart-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
|
||||
.no-data-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
|
||||
i { font-size: 1.4rem; }
|
||||
}
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-state-message {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: #e74c3c;
|
||||
font-size: 0.95rem;
|
||||
|
||||
i { font-size: 1.4rem; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||
import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
|
||||
import { UnitUtils } from '../../../shared/utils';
|
||||
|
||||
export { TrendDataPoint as HectaresTrendDataPoint };
|
||||
|
||||
let roundedBarsPatched = false;
|
||||
let tooltipPositionerPatched = false;
|
||||
|
||||
/**
|
||||
* Registers a custom Chart.js 2.x tooltip positioner that clamps the tooltip
|
||||
* inside the chart area. The default 'average' positioner anchors the tooltip
|
||||
* above the bar top — for truncated bars (value > axis max) this falls outside
|
||||
* the canvas and becomes invisible. This positioner ensures it always stays inside.
|
||||
*/
|
||||
function patchTooltipPositioner(): void {
|
||||
if (tooltipPositionerPatched) { return; }
|
||||
const C: any = (window as any).Chart;
|
||||
if (!C?.Tooltip?.positioners) { return; }
|
||||
tooltipPositionerPatched = true;
|
||||
|
||||
C.Tooltip.positioners.clampedTop = function(elements: any[], eventPosition: any) {
|
||||
const pos = C.Tooltip.positioners.average.call(this, elements, eventPosition);
|
||||
if (!pos) { return false; }
|
||||
const chartArea = this._chart?.chartArea;
|
||||
if (!chartArea) { return pos; }
|
||||
// Keep the tooltip anchor at least 8px below the chart top so the tooltip
|
||||
// box always renders inside the canvas instead of above it.
|
||||
return { x: pos.x, y: Math.max(chartArea.top + 8, pos.y) };
|
||||
};
|
||||
}
|
||||
|
||||
function patchRoundedBars(): void {
|
||||
if (roundedBarsPatched) { return; }
|
||||
const C: any = (window as any).Chart;
|
||||
if (!C || !C.elements || !C.elements.Rectangle) { return; }
|
||||
roundedBarsPatched = true;
|
||||
|
||||
const origDraw = C.elements.Rectangle.prototype.draw;
|
||||
C.elements.Rectangle.prototype.draw = function() {
|
||||
const ctx: CanvasRenderingContext2D = this._chart.ctx;
|
||||
const vm = this._view;
|
||||
|
||||
// Only apply to vertical bars
|
||||
if (vm.horizontal || vm.base == null) {
|
||||
origDraw.call(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const x = vm.x;
|
||||
const width = vm.width;
|
||||
const top = Math.min(vm.y, vm.base);
|
||||
const bottom = Math.max(vm.y, vm.base);
|
||||
const left = x - width / 2;
|
||||
const right = x + width / 2;
|
||||
const r = Math.min(6, width / 2, (bottom - top) / 2);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + r, top);
|
||||
ctx.lineTo(right - r, top);
|
||||
ctx.quadraticCurveTo(right, top, right, top + r);
|
||||
ctx.lineTo(right, bottom);
|
||||
ctx.lineTo(left, bottom);
|
||||
ctx.lineTo(left, top + r);
|
||||
ctx.quadraticCurveTo(left, top, left + r, top);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = vm.backgroundColor;
|
||||
ctx.fill();
|
||||
|
||||
if (vm.borderWidth) {
|
||||
ctx.strokeStyle = vm.borderColor;
|
||||
ctx.lineWidth = vm.borderWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'agm-hectares-chart',
|
||||
templateUrl: './hectares-chart.component.html',
|
||||
styleUrls: ['./hectares-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HectaresChartComponent implements OnChanges, AfterViewInit {
|
||||
@Input() trendData: TrendDataPoint[] = [];
|
||||
@Input() isLoading = false;
|
||||
@Input() hasError = false;
|
||||
@Input() isUS = false;
|
||||
/** The data-point index currently selected by a drill-down click. null = no selection. */
|
||||
@Input() selectedIndex: number | null = null;
|
||||
|
||||
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
|
||||
|
||||
chartData: any;
|
||||
chartOptions: any;
|
||||
hasData = false;
|
||||
|
||||
get areaLabel(): string {
|
||||
return this.isUS
|
||||
? $localize`:Acres sprayed chart title@@pilotAcresChartTitle:Acres Sprayed Per Day`
|
||||
: $localize`:Hectares sprayed chart title@@pilotHectaresChartTitle:Hectares Sprayed Per Day`;
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
patchRoundedBars();
|
||||
patchTooltipPositioner();
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (!this.isLoading && !this.hasError) {
|
||||
const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
|
||||
const convertedData = this.trendData.map(d => ({
|
||||
day: d.day,
|
||||
value: UnitUtils.haToArea(d.value, this.isUS)
|
||||
}));
|
||||
this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0);
|
||||
const config = ChartBuilderUtils.hectaresChart(convertedData, areaUnit);
|
||||
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
|
||||
const count: number = config.chartData.datasets[0].data.length;
|
||||
config.chartData = {
|
||||
...config.chartData,
|
||||
datasets: [{
|
||||
...config.chartData.datasets[0],
|
||||
backgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
|
||||
}]
|
||||
};
|
||||
}
|
||||
this.chartData = config.chartData;
|
||||
this.chartOptions = config.chartOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
|
||||
onDataSelect(event: any): void {
|
||||
const index: number = event?.element?._index;
|
||||
if (index == null || isNaN(index)) { return; }
|
||||
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<div class="hours-chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title" i18n="Hours flown chart title@@pilotHoursChartTitle">Hours Flown (Week History)</h3>
|
||||
</div>
|
||||
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
||||
<p-chart type="line" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
|
||||
<div class="no-data-overlay" *ngIf="!hasData">
|
||||
<i class="pi pi-calendar-times"></i>
|
||||
<span i18n="Hours chart no data@@pilotHoursChartNoData">No flight activity for this period</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-loading" *ngIf="isLoading">
|
||||
<p-progressSpinner strokeWidth="3" [style]="{width: '40px', height: '40px'}"></p-progressSpinner>
|
||||
</div>
|
||||
<div class="chart-state-message" *ngIf="!isLoading && hasError">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span i18n="Hours chart error@@errorLoadingHoursChart">Failed to load chart data</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,60 @@
|
||||
.hours-chart-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
|
||||
.no-data-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
|
||||
i { font-size: 1.4rem; }
|
||||
}
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-state-message {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: #e74c3c;
|
||||
font-size: 0.95rem;
|
||||
|
||||
i { font-size: 1.4rem; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||
import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
|
||||
|
||||
export { TrendDataPoint };
|
||||
|
||||
@Component({
|
||||
selector: 'agm-hours-chart',
|
||||
templateUrl: './hours-chart.component.html',
|
||||
styleUrls: ['./hours-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HoursChartComponent implements OnChanges {
|
||||
@Input() trendData: TrendDataPoint[] = [];
|
||||
@Input() isLoading = false;
|
||||
@Input() hasError = false;
|
||||
/** The data-point index currently selected by a drill-down click. null = no selection. */
|
||||
@Input() selectedIndex: number | null = null;
|
||||
|
||||
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
|
||||
|
||||
chartData: any;
|
||||
chartOptions: any;
|
||||
hasData = false;
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (!this.isLoading && !this.hasError) {
|
||||
this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0);
|
||||
const config = ChartBuilderUtils.hoursChart(this.trendData);
|
||||
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
|
||||
const count: number = config.chartData.datasets[0].data.length;
|
||||
config.chartData = {
|
||||
...config.chartData,
|
||||
datasets: [{
|
||||
...config.chartData.datasets[0],
|
||||
pointBackgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
|
||||
pointRadius: Array.from({ length: count }, (_, i) => i === this.selectedIndex ? 7 : 4),
|
||||
}]
|
||||
};
|
||||
}
|
||||
this.chartData = config.chartData;
|
||||
this.chartOptions = config.chartOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
|
||||
onDataSelect(event: any): void {
|
||||
const index: number = event?.element?._index;
|
||||
if (index == null || isNaN(index)) { return; }
|
||||
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-header">
|
||||
<i *ngIf="icon" [ngClass]="icon" class="kpi-icon"></i>
|
||||
<span class="kpi-label">{{ label }}</span>
|
||||
</div>
|
||||
<div class="kpi-value">
|
||||
{{ value | number:'1.0-2' }}<span class="kpi-unit" *ngIf="unit"> {{ unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-historical" *ngIf="historicalParts.length">
|
||||
<span *ngFor="let part of historicalParts; let last = last" class="hist-part">
|
||||
{{ part }}<span *ngIf="!last" class="hist-sep">|</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,111 @@
|
||||
:host {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 16px 0 rgba(44,62,80,0.10), 0 1.5px 4px 0 rgba(44,62,80,0.06);
|
||||
padding: 1.25rem 1.5rem;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
font-size: 1rem;
|
||||
color: #388e3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #16181c;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1a211c;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #6b7680;
|
||||
}
|
||||
|
||||
.kpi-historical {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
|
||||
.hist-part {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
color: #9e9e9e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hist-sep {
|
||||
margin: 0 0.55rem;
|
||||
color: #d0d5db;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tablet ≤1024px ─────────────────────────────────────────────────────────
|
||||
@media (max-width: 1024px) {
|
||||
.kpi-value { font-size: 1.75rem; }
|
||||
}
|
||||
|
||||
// ── Mobile landscape ≤768px — wrap to 2 per row ───────────────────────────
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
flex: 1 1 calc(50% - 0.5rem);
|
||||
max-width: calc(50% - 0.5rem);
|
||||
}
|
||||
.kpi-card { padding: 1rem 1.25rem; }
|
||||
.kpi-value { font-size: 1.5rem; }
|
||||
.kpi-unit { font-size: 0.9rem; }
|
||||
.kpi-label { font-size: 0.82rem; }
|
||||
.kpi-historical .hist-part { font-size: 0.72rem; }
|
||||
.kpi-historical .hist-sep { margin: 0 0.35rem; }
|
||||
}
|
||||
|
||||
// ── Small mobile ≤480px ────────────────────────────────────────────────────
|
||||
@media (max-width: 480px) {
|
||||
:host {
|
||||
flex: 1 1 calc(50% - 0.375rem);
|
||||
max-width: calc(50% - 0.375rem);
|
||||
}
|
||||
.kpi-card { padding: 0.875rem 1rem; border-radius: 16px; }
|
||||
.kpi-value { font-size: 1.35rem; margin-bottom: 0.25rem; }
|
||||
.kpi-unit { font-size: 0.82rem; }
|
||||
.kpi-label { font-size: 0.78rem; }
|
||||
.kpi-historical .hist-part { font-size: 0.68rem; }
|
||||
.kpi-historical .hist-sep { margin: 0 0.25rem; }
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { Component, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-kpi-card',
|
||||
templateUrl: './kpi-card.component.html',
|
||||
styleUrls: ['./kpi-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class KpiCardComponent implements OnChanges {
|
||||
@Input() icon = '';
|
||||
@Input() label = '';
|
||||
@Input() value: string | number = '';
|
||||
@Input() unit = '';
|
||||
@Input() historical: { year?: string | number; month?: string | number; week?: string | number; day?: string | number } = {};
|
||||
|
||||
historicalParts: string[] = [];
|
||||
|
||||
ngOnChanges(): void {
|
||||
const fmt = (v: string | number) => (+v % 1 === 0) ? `${+v}` : (+v).toFixed(2).replace(/\.?0+$/, '');
|
||||
const parts: string[] = [];
|
||||
if (this.historical.day != null) { parts.push(`Y: ${fmt(this.historical.day)}`); }
|
||||
if (this.historical.week != null) { parts.push(`W: ${fmt(this.historical.week)}`); }
|
||||
if (this.historical.month != null) { parts.push(`M: ${fmt(this.historical.month)}`); }
|
||||
if (this.historical.year != null) { parts.push(`Yr: ${fmt(this.historical.year)}`); }
|
||||
this.historicalParts = parts;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<div class="operations-today-strip">
|
||||
<div class="row">
|
||||
<div class="metric title-metric">
|
||||
<span class="operations-today-title" i18n="Operations today card title@@pilotOperationsTodayTitle">Operations Today</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="icon"><i class="pi pi-map-marker"></i></span>
|
||||
<span class="label" i18n="Distance label@@pilotDistanceLabel">Distance</span>
|
||||
<span class="value">{{ displayDistanceValue | number:'1.0-0' }} {{ displayDistanceUnit }}</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="icon"><i class="pi pi-tint"></i></span>
|
||||
<span class="label" i18n="Spray volume label@@pilotSprayVolumeLabel">Spray Volume</span>
|
||||
<span class="value">{{ displayVolumeValue | number:'1.0-0' }} {{ displayVolumeUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,111 @@
|
||||
.operations-today-strip {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44,62,80,0.06), 0 1.5px 4px 0 rgba(44,62,80,0.03);
|
||||
padding: 2rem 2.5rem 1.5rem 2.5rem;
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
.metric {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
padding: 0 1.5rem;
|
||||
box-sizing: border-box;
|
||||
.icon {
|
||||
font-size: 1.3rem;
|
||||
color: #232a2f;
|
||||
margin-bottom: 0.2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7680;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
.value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #1a211c;
|
||||
margin-bottom: 0.2rem;
|
||||
display: block;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
.title-metric {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
.operations-today-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #1a211c;
|
||||
letter-spacing: 0.01em;
|
||||
margin-bottom: 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet ≤1024px
|
||||
@media (max-width: 1024px) {
|
||||
.operations-today-strip {
|
||||
padding: 1.5rem 2rem;
|
||||
|
||||
.metric .value { font-size: 1.45rem; }
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile ≤768px
|
||||
@media (max-width: 768px) {
|
||||
.operations-today-strip {
|
||||
padding: 1.25rem 1.5rem;
|
||||
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.title-metric {
|
||||
flex: 1 1 100%;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
.operations-today-title { font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.metric {
|
||||
flex: 1 1 0;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
.icon { font-size: 1.1rem; }
|
||||
.label { font-size: 0.8rem; margin-bottom: 0.25rem; }
|
||||
.value { font-size: 1.35rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small mobile ≤480px
|
||||
@media (max-width: 480px) {
|
||||
.operations-today-strip {
|
||||
padding: 1rem;
|
||||
border-radius: 16px;
|
||||
|
||||
.metric {
|
||||
padding: 0 0.25rem;
|
||||
|
||||
.label { font-size: 0.75rem; }
|
||||
.value { font-size: 1.2rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { UnitUtils } from '@app/shared/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-operations-today',
|
||||
templateUrl: './operations-today.component.html',
|
||||
styleUrls: ['./operations-today.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class OperationsTodayComponent {
|
||||
@Input() distanceKm: number = 0;
|
||||
@Input() sprayVolumeL: number = 0;
|
||||
@Input() isUS = false;
|
||||
|
||||
get displayDistanceValue(): number {
|
||||
return UnitUtils.kmToDistance(this.distanceKm, this.isUS);
|
||||
}
|
||||
|
||||
get displayDistanceUnit(): string {
|
||||
return UnitUtils.distanceUnitLabel(this.isUS);
|
||||
}
|
||||
|
||||
get displayVolumeValue(): number {
|
||||
return UnitUtils.litersToVolume(this.sprayVolumeL, this.isUS);
|
||||
}
|
||||
|
||||
get displayVolumeUnit(): string {
|
||||
return UnitUtils.volumeUnitLabel(this.isUS);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<div class="summary-strip">
|
||||
<div class="summary-strip-title" i18n="Daily summary title@@pilotDailySummaryTitle">Daily Summary — Today vs Yesterday</div>
|
||||
<div class="summary-items-row">
|
||||
<div class="summary-item" *ngFor="let item of summary">
|
||||
<span class="label">{{ item.label }}</span>
|
||||
<span class="value">
|
||||
{{ item.value | number:'1.0-2' }}<span *ngIf="item.unit"> {{ item.unit }}</span>
|
||||
</span>
|
||||
<span class="change"
|
||||
[ngClass]="{
|
||||
'change-pos': item.change && item.change.startsWith('+'),
|
||||
'change-neg': item.change && item.change.startsWith('-'),
|
||||
'change-neutral': item.change && (item.change.startsWith('=') || item.change.startsWith('\u2248'))
|
||||
}"
|
||||
>
|
||||
<span class="change-arrow" *ngIf="item.change && item.change.startsWith('+')">↑</span>
|
||||
<span class="change-arrow" *ngIf="item.change && item.change.startsWith('-')">↓</span>
|
||||
{{ item.change }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,127 @@
|
||||
.summary-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #256029;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1.2rem 1.5rem;
|
||||
|
||||
.summary-strip-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-items-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 1.25rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.4rem;
|
||||
bottom: 0.4rem;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0.25rem;
|
||||
&::before { display: none; }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 0.2rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
.change-arrow { font-style: normal; }
|
||||
&.change-pos { color: #9be49f; }
|
||||
&.change-neg { color: #ef9a9a; }
|
||||
&.change-neutral { color: #ffd54f; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet
|
||||
@media (max-width: 1024px) {
|
||||
.summary-strip {
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
.summary-item {
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
.value { font-size: 1.45rem; }
|
||||
.label { font-size: 0.78rem; }
|
||||
.change { font-size: 0.78rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 640px) {
|
||||
.summary-strip {
|
||||
padding: 0.75rem;
|
||||
|
||||
.summary-strip-title { font-size: 0.88rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.summary-items-row { gap: 0.25rem; justify-content: flex-start; }
|
||||
|
||||
.summary-item {
|
||||
flex: 0 0 calc(33% - 0.25rem);
|
||||
min-width: calc(33% - 0.25rem);
|
||||
padding: 0.4rem 0.6rem;
|
||||
|
||||
&::before { background: rgba(255, 255, 255, 0.25); }
|
||||
&:first-child { padding-left: 0.1rem; &::before { display: none; } }
|
||||
|
||||
.label { font-size: 0.73rem; }
|
||||
.value { font-size: 1.2rem; }
|
||||
.change { font-size: 0.7rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small mobile
|
||||
@media (max-width: 400px) {
|
||||
.summary-strip .summary-item {
|
||||
flex: 0 0 calc(50% - 0.25rem);
|
||||
min-width: calc(50% - 0.25rem);
|
||||
|
||||
.value { font-size: 1.1rem; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-summary-strip',
|
||||
templateUrl: './summary-strip.component.html',
|
||||
styleUrls: ['./summary-strip.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SummaryStripComponent {
|
||||
@Input() summary: Array<{ label: string; value: number; unit: string; change: string }> = [];
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<div class="xt-error-card">
|
||||
|
||||
<div class="card-header">
|
||||
<h3 class="card-title" i18n="XT error indicator card title@@pilotXtErrorTitle">Average XT Error</h3>
|
||||
</div>
|
||||
|
||||
<div class="state-message" *ngIf="isLoading">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</div>
|
||||
|
||||
<div class="state-message error-state" *ngIf="!isLoading && hasError">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span i18n="XT error load failure@@pilotXtErrorLoadFail">Failed to load performance data.</span>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isLoading && !hasError">
|
||||
|
||||
<ng-container *ngIf="value !== null; else noDataBody">
|
||||
|
||||
<div class="value-row">
|
||||
<span class="value-number">{{ displayValue | number:'1.1-1' }}</span>
|
||||
<span class="value-unit">{{ displayUnit }}</span>
|
||||
</div>
|
||||
|
||||
<div class="band-badge-row">
|
||||
<span class="band-badge" [ngClass]="'badge-' + band">{{ bandLabel }}</span>
|
||||
</div>
|
||||
|
||||
<agm-band-bar [bar]="bar"></agm-band-bar>
|
||||
|
||||
<div class="threshold-labels">
|
||||
<span class="tl-label tl-start">0 {{ displayUnit }}</span>
|
||||
<span class="tl-label tl-mid" [style.left.%]="bar.goodPct">{{ displayThresholdGood | number:'1.0-1' }} {{ displayUnit }}</span>
|
||||
<span class="tl-label tl-mid" [style.left.%]="bar.monitorPct">{{ displayThresholdMonitor | number:'1.0-1' }} {{ displayUnit }}</span>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-item">< {{ displayThresholdGood | number:'1.0-1' }} {{ displayUnit }} ideal</span>
|
||||
<span class="legend-sep">|</span>
|
||||
<span class="legend-item">{{ displayThresholdGood | number:'1.0-1' }}–{{ displayThresholdMonitor | number:'1.0-1' }} {{ displayUnit }} caution</span>
|
||||
<span class="legend-sep">|</span>
|
||||
<span class="legend-item">> {{ displayThresholdMonitor | number:'1.0-1' }} {{ displayUnit }} high</span>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noDataBody>
|
||||
<div class="no-data-body">
|
||||
<i class="pi pi-chart-bar no-data-icon"></i>
|
||||
<span i18n="XT error no data message@@pilotXtNoData">No spray data available</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,29 @@
|
||||
@import '../../styles/indicator-card';
|
||||
|
||||
:host { display: block; }
|
||||
|
||||
.xt-error-card {
|
||||
@include indicator-card($badge-row-margin: 0.75rem, $value-row-margin: 0.75rem);
|
||||
|
||||
.sample-size {
|
||||
font-size: 0.72rem;
|
||||
color: #bdbdbd;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.no-data-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: #9e9e9e;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { PilotXtThreshold } from '../../../domain/models/pilot-dashboard.model';
|
||||
import { IndicatorBand, BarState, classifyBand, getBandLabel, computeBarState } from '../../utils/indicator-band.utils';
|
||||
import { UnitUtils } from '../../../shared/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-xt-error-indicator',
|
||||
templateUrl: './xt-error-indicator.component.html',
|
||||
styleUrls: ['./xt-error-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class XtErrorIndicatorComponent {
|
||||
@Input() value: number | null = null;
|
||||
@Input() threshold: PilotXtThreshold = { good: 1.0, monitor: 3.0 };
|
||||
@Input() sampleSize = 0;
|
||||
@Input() isUS = false;
|
||||
@Input() isLoading = false;
|
||||
@Input() hasError = false;
|
||||
|
||||
get displayUnit(): string {
|
||||
return UnitUtils.lengthUnitLabel(this.isUS);
|
||||
}
|
||||
|
||||
get displayValue(): number | null {
|
||||
return this.value !== null ? (this.isUS ? UnitUtils.mToFt(this.value) : this.value) : null;
|
||||
}
|
||||
|
||||
get displayThresholdGood(): number {
|
||||
return this.isUS ? UnitUtils.mToFt(this.threshold.good) : this.threshold.good;
|
||||
}
|
||||
|
||||
get displayThresholdMonitor(): number {
|
||||
return this.isUS ? UnitUtils.mToFt(this.threshold.monitor) : this.threshold.monitor;
|
||||
}
|
||||
|
||||
get band(): IndicatorBand {
|
||||
return classifyBand(this.value ?? 0, this.threshold.good, this.threshold.monitor);
|
||||
}
|
||||
|
||||
get bandLabel(): string {
|
||||
return getBandLabel(this.band);
|
||||
}
|
||||
|
||||
get bar(): BarState {
|
||||
return computeBarState(this.threshold.good, this.threshold.monitor, this.value ?? 0);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,12 @@
|
||||
|
||||
<div class="ui-g">
|
||||
<ng-container [ngTemplateOutlet]="disclaimerSection"></ng-container>
|
||||
<ng-container *ngIf="isPilotUser; then pilotDashboard; else disclaimerSection"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #pilotDashboard>
|
||||
<agm-pilot-dashboard></agm-pilot-dashboard>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #disclaimerSection>
|
||||
<section class="ui-g-12">
|
||||
<div class="card card-title">
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { BaseComp } from '../shared/base/base.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.css']
|
||||
})
|
||||
export class DashboardComponent {
|
||||
|
||||
constructor() { }
|
||||
export class DashboardComponent extends BaseComp {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
<div class="pilot-dashboard-grid">
|
||||
<div class="dashboard-header">
|
||||
<h1 class="dashboard-title" i18n="@@pilotDashboardTitle">Pilot Analytical Dashboard</h1>
|
||||
<div class="dashboard-header-right">
|
||||
<span class="unit-toggle-label" i18n="@@unitToggleMetricLabel">Metric</span>
|
||||
<p-inputSwitch
|
||||
[ngModel]="isUS"
|
||||
(onChange)="onUnitToggle($event.checked)"
|
||||
inputId="dashUnitToggle">
|
||||
</p-inputSwitch>
|
||||
<label for="dashUnitToggle" class="unit-toggle-label" i18n="@@unitToggleUSLabel">US / Imperial</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drill-down filter pill: visible when a chart bar/point is selected -->
|
||||
<div class="drill-down-filter" *ngIf="selectedDayLabel">
|
||||
<i class="pi pi-filter-fill"></i>
|
||||
<span i18n="Drill-down filter label@@drillDownFilterLabel">Viewing data for:</span>
|
||||
<strong class="drill-down-day">{{ selectedDayLabel }}</strong>
|
||||
<button class="drill-down-clear" (click)="clearDaySelection()"
|
||||
pTooltip="Clear day filter" i18n-pTooltip="@@drillDownClearTooltip">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<agm-kpi-card
|
||||
*ngFor="let kpi of kpiData; let i = index; trackBy: trackByIndex"
|
||||
[icon]="kpiIcons[i]"
|
||||
[label]="kpi.label"
|
||||
[value]="kpi.value"
|
||||
[unit]="kpi.unit"
|
||||
[historical]="kpi.historical"
|
||||
></agm-kpi-card>
|
||||
</div>
|
||||
<div class="main-content-row">
|
||||
<div class="left-col">
|
||||
<agm-summary-strip [summary]="summaryData"></agm-summary-strip>
|
||||
<agm-operations-today [distanceKm]="distanceKm" [sprayVolumeL]="sprayVolumeL" [isUS]="isUS"></agm-operations-today>
|
||||
<agm-active-jobs [jobs]="activeJobs" [isLoading]="activeJobsLoading" [hasError]="hasActiveJobsError" [isUS]="isUS"></agm-active-jobs>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<agm-date-range-control (rangeChange)="onTrendRangeChange($event)"></agm-date-range-control>
|
||||
<agm-hours-chart
|
||||
[trendData]="trendData"
|
||||
[isLoading]="trendLoading"
|
||||
[hasError]="hasTrendError"
|
||||
[selectedIndex]="selectedIndex"
|
||||
(daySelected)="onDaySelected($event)">
|
||||
</agm-hours-chart>
|
||||
<agm-hectares-chart
|
||||
[trendData]="hectaresTrendData"
|
||||
[isLoading]="trendLoading"
|
||||
[hasError]="hasTrendError"
|
||||
[isUS]="isUS"
|
||||
[selectedIndex]="selectedIndex"
|
||||
(daySelected)="onDaySelected($event)">
|
||||
</agm-hectares-chart>
|
||||
<agm-xt-error-indicator
|
||||
[value]="xtErrorValue"
|
||||
[threshold]="xtThreshold"
|
||||
[sampleSize]="xtSampleSize"
|
||||
[isUS]="isUS"
|
||||
[isLoading]="performanceLoading"
|
||||
[hasError]="hasPerformanceError"
|
||||
></agm-xt-error-indicator>
|
||||
<agm-altitude-indicator
|
||||
[value]="altValue"
|
||||
[threshold]="altThreshold"
|
||||
[source]="altSource"
|
||||
[hasData]="altHasData"
|
||||
[sampleSize]="altSampleSize"
|
||||
[isUS]="isUS"
|
||||
[isLoading]="performanceLoading"
|
||||
[hasError]="hasPerformanceError"
|
||||
></agm-altitude-indicator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,144 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pilot-dashboard-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 1.5rem 2rem;
|
||||
background: #f0f3f7;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.unit-toggle-label {
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Drill-down filter pill — shown when a chart day is selected
|
||||
.drill-down-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 2rem;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
|
||||
font-size: 0.875rem;
|
||||
color: #1b5e20;
|
||||
width: fit-content;
|
||||
|
||||
.pi-filter-fill { font-size: 0.85rem; color: #2e7d32; }
|
||||
}
|
||||
|
||||
.drill-down-day {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drill-down-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #2e7d32;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #c8e6c9; }
|
||||
.pi-times { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.main-content-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex: 1 1 auto;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-col {
|
||||
flex: 1 1 65%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-col {
|
||||
flex: 1 1 35%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background: #f4f4f4;
|
||||
border: 1px dashed #bbb;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.main-content-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.left-col, .right-col {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pilot-dashboard-grid {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.kpi-row { gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pilot-dashboard-grid {
|
||||
padding: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.kpi-row { gap: 0.5rem; }
|
||||
}
|
||||
@ -0,0 +1,351 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DateRangeSelection } from '../../shared/date-range-control/date-range-control.component';
|
||||
import { DateUtils, UnitUtils } from '../../shared/utils';
|
||||
import { TrendDataPoint } from '../utils/chart-builders';
|
||||
import { PilotDashboardService } from '@app/domain/services/pilot-dashboard.service';
|
||||
import { PilotHistoricalMetric, PilotKpiResponse, PilotSummaryResponse, PilotActiveJob, PilotActiveJobsResponse, PilotTrendResponse, PilotPerformanceResponse, PilotXtThreshold, PilotAltitudeThreshold } from '@app/domain/models/pilot-dashboard.model';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
interface KpiCardData {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
historical: PilotHistoricalMetric;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'agm-pilot-dashboard',
|
||||
templateUrl: './pilot-dashboard.component.html',
|
||||
styleUrls: ['./pilot-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
private static readonly KPI_ICONS: readonly string[] = [
|
||||
'pi pi-briefcase',
|
||||
'pi pi-leaf',
|
||||
'pi pi-send',
|
||||
'pi pi-clock'
|
||||
];
|
||||
|
||||
|
||||
summaryData: Array<{ label: string; value: number; unit: string; change: string }> = [];
|
||||
kpiData: KpiCardData[] = [];
|
||||
distanceKm = 0;
|
||||
sprayVolumeL = 0;
|
||||
activeJobs: PilotActiveJob[] = [];
|
||||
activeJobsLoading = true;
|
||||
hasActiveJobsError = false;
|
||||
readonly kpiIcons = PilotDashboardComponent.KPI_ICONS;
|
||||
|
||||
xtErrorValue: number | null = null;
|
||||
xtThreshold: PilotXtThreshold = { good: 1.0, monitor: 3.0 };
|
||||
xtSampleSize = 0;
|
||||
altValue: number | null = null;
|
||||
altThreshold: PilotAltitudeThreshold = { target: 3.658, goodDelta: 0.152, monitorDelta: 0.457 };
|
||||
altSource: 'sprayHeight' | 'radarAlt' | 'gpsAlt' | 'none' = 'none';
|
||||
altHasData = false;
|
||||
altSampleSize = 0;
|
||||
performanceLoading = false;
|
||||
hasPerformanceError = false;
|
||||
|
||||
trendData: TrendDataPoint[] = [];
|
||||
hectaresTrendData: TrendDataPoint[] = [];
|
||||
trendLoading = false;
|
||||
hasTrendError = false;
|
||||
|
||||
/** Index of the currently drill-down-selected chart bar/point. null = no selection. */
|
||||
selectedIndex: number | null = null;
|
||||
/** Human-readable label of the selected day (e.g. "Mon Apr 7"), shown in the filter pill. */
|
||||
selectedDayLabel: string | null = null;
|
||||
/** ISO date string of the selected day, passed as ?selectedDate= query param to API calls. */
|
||||
private selectedDayDate: string | undefined = undefined;
|
||||
/** Start date of the current trend range, used to map bar index → calendar date. */
|
||||
private trendRangeStart: Date | null = null;
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
|
||||
constructor(
|
||||
private readonly pilotDashboardService: PilotDashboardService,
|
||||
cdRef: ChangeDetectorRef
|
||||
) { super(cdRef); }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetchKpiData();
|
||||
this.fetchSummaryData();
|
||||
this.fetchActiveJobsData();
|
||||
this.fetchPerformanceData();
|
||||
}
|
||||
|
||||
// Override BaseComp.isUS to read live from appConf instead of the stale
|
||||
// one-time cloned this.settings, so toggling reflects immediately.
|
||||
get isUS(): boolean {
|
||||
return this.appConf.settings?.measureUnit;
|
||||
}
|
||||
|
||||
onUnitToggle(isUS: boolean): void {
|
||||
this.appConf.settings = { ...this.appConf.settings, measureUnit: isUS };
|
||||
this.settings = this.appConf.settings; // keep BaseComp clone in sync
|
||||
this.appConf.save(null, true);
|
||||
this.cdRef?.markForCheck(); // re-evaluate [isUS]="isUS" bindings on child components
|
||||
// Re-fetch with current drill-down filter (if any) so unit-toggled labels are consistent.
|
||||
this.fetchKpiData(this.selectedDayDate);
|
||||
this.fetchSummaryData(this.selectedDayDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* PowerBI-style drill-down: clicking a chart bar/point filters all dashboard sections
|
||||
* to show data for that specific day. Clicking the same day again clears the filter.
|
||||
*
|
||||
* All four data sections (KPI, Summary, Active Jobs, Performance) are re-fetched
|
||||
* from the API with ?selectedDate= so every metric reflects the chosen day.
|
||||
* detectChanges() is called immediately after updating the selection state so the
|
||||
* filter pill and chart highlights render before the API responses arrive.
|
||||
*/
|
||||
onDaySelected(event: { label: string; index: number }): void {
|
||||
if (this.selectedIndex === event.index) {
|
||||
this.clearDaySelection();
|
||||
return;
|
||||
}
|
||||
if (!this.trendRangeStart) { return; }
|
||||
this.selectedDayDate = DateUtils.toIsoDate(DateUtils.addDays(this.trendRangeStart, event.index));
|
||||
this.selectedDayLabel = event.label;
|
||||
this.selectedIndex = event.index;
|
||||
// Immediately propagate state to OnPush children (filter pill + chart highlights)
|
||||
// before the API responses arrive.
|
||||
this.cdRef?.detectChanges();
|
||||
this.fetchKpiData(this.selectedDayDate);
|
||||
this.fetchSummaryData(this.selectedDayDate);
|
||||
this.fetchActiveJobsData(this.selectedDayDate);
|
||||
this.fetchPerformanceData(this.selectedDayDate);
|
||||
}
|
||||
|
||||
/** Clears the active drill-down filter and restores the full-range data. */
|
||||
clearDaySelection(): void {
|
||||
this.selectedIndex = null;
|
||||
this.selectedDayLabel = null;
|
||||
this.selectedDayDate = undefined;
|
||||
this.cdRef?.detectChanges();
|
||||
// Restore full-range values from the API for all sections.
|
||||
this.fetchKpiData();
|
||||
this.fetchSummaryData();
|
||||
this.fetchActiveJobsData();
|
||||
this.fetchPerformanceData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
/** Converts a historical metric's area values from hectares to the current unit system. */
|
||||
private convertHaHistory(h: PilotHistoricalMetric): PilotHistoricalMetric {
|
||||
return {
|
||||
day: UnitUtils.haToArea(h.day, this.isUS),
|
||||
week: UnitUtils.haToArea(h.week, this.isUS),
|
||||
month: UnitUtils.haToArea(h.month, this.isUS),
|
||||
year: UnitUtils.haToArea(h.year, this.isUS),
|
||||
};
|
||||
}
|
||||
|
||||
private fetchKpiData(date?: string): void {
|
||||
this.pilotDashboardService.getKpi(date).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (res: PilotKpiResponse) => {
|
||||
const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
|
||||
const assignedAreaLabel = this.isUS
|
||||
? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres`
|
||||
: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`;
|
||||
const sprayedAreaLabel = this.isUS
|
||||
? $localize`:KPI label US@@kpiAcresToday:Acres Sprayed Today`
|
||||
: $localize`:KPI label@@kpiHectaresToday:Hectares Sprayed Today`;
|
||||
this.kpiData = [
|
||||
{
|
||||
label: $localize`:KPI label@@kpiAssignedJobs:Assigned Jobs`,
|
||||
value: res.assignedJobs,
|
||||
unit: '',
|
||||
historical: res.historical.jobs
|
||||
},
|
||||
{
|
||||
label: assignedAreaLabel,
|
||||
value: UnitUtils.haToArea(res.assignedHectares, this.isUS),
|
||||
unit: areaUnit,
|
||||
historical: this.convertHaHistory(res.historical.hectares)
|
||||
},
|
||||
{
|
||||
label: sprayedAreaLabel,
|
||||
value: UnitUtils.haToArea(res.sprayedToday, this.isUS),
|
||||
unit: areaUnit,
|
||||
historical: this.convertHaHistory(res.historical.hectares)
|
||||
},
|
||||
{
|
||||
label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`,
|
||||
value: res.flightHoursToday,
|
||||
unit: 'hrs',
|
||||
historical: res.historical.flightHours
|
||||
}
|
||||
];
|
||||
this.distanceKm = res.operations.distanceKm;
|
||||
this.sprayVolumeL = res.operations.sprayVolumeLiters;
|
||||
this.cdRef?.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.msgSvc.addFailedMsg($localize`:@@errorLoadingKpi:Failed to load KPI data. Please refresh the page.`);
|
||||
this.cdRef?.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchSummaryData(date?: string): void {
|
||||
this.pilotDashboardService.getSummary(date).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (res: PilotSummaryResponse) => {
|
||||
const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
|
||||
const speedUnit = UnitUtils.speedUnitLabel(this.isUS);
|
||||
const volumeUnit = UnitUtils.volumeUnitLabel(this.isUS);
|
||||
this.summaryData = [
|
||||
{
|
||||
label: this.isUS
|
||||
? $localize`:Summary label US@@summaryAcres:Acres`
|
||||
: $localize`:Summary label@@summaryHectares:Hectares`,
|
||||
value: UnitUtils.haToArea(res.today.hectares, this.isUS),
|
||||
unit: areaUnit,
|
||||
change: this.formatPct(res.deltas.hectaresPct)
|
||||
},
|
||||
{
|
||||
label: $localize`:Summary label@@summaryFlightHours:Flight Hours`,
|
||||
value: res.today.flightHours,
|
||||
unit: 'hrs',
|
||||
change: this.formatPct(res.deltas.flightHoursPct)
|
||||
},
|
||||
{
|
||||
label: `${areaUnit}/hr`,
|
||||
value: UnitUtils.haToArea(res.today.haPerHour, this.isUS),
|
||||
unit: '',
|
||||
change: this.formatPct(res.deltas.haPerHourPct)
|
||||
},
|
||||
{
|
||||
label: $localize`:Summary label@@summaryAvgSpeed:Avg Speed`,
|
||||
value: UnitUtils.speedToDisplay(res.today.avgSpeedKmh, this.isUS),
|
||||
unit: speedUnit,
|
||||
change: this.formatPct(res.deltas.avgSpeedPct)
|
||||
},
|
||||
{
|
||||
label: $localize`:Summary label@@summarySprayVolume:Spray Volume`,
|
||||
value: UnitUtils.litersToVolume(res.today.sprayVolumeLiters, this.isUS),
|
||||
unit: volumeUnit,
|
||||
change: this.formatPct(res.deltas.sprayVolumePct)
|
||||
}
|
||||
];
|
||||
this.cdRef?.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.msgSvc.addFailedMsg($localize`:@@errorLoadingSummary:Failed to load daily summary. Please refresh the page.`);
|
||||
this.cdRef?.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchActiveJobsData(date?: string): void {
|
||||
this.pilotDashboardService.getActiveJobs(date).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (res: PilotActiveJobsResponse) => {
|
||||
this.activeJobs = res.jobs;
|
||||
this.activeJobsLoading = false;
|
||||
this.cdRef?.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.activeJobsLoading = false;
|
||||
this.hasActiveJobsError = true;
|
||||
this.cdRef?.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchPerformanceData(date?: string): void {
|
||||
this.performanceLoading = true;
|
||||
this.pilotDashboardService.getPerformance(date).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (res: PilotPerformanceResponse) => {
|
||||
this.xtErrorValue = res.avgXtErrorMeters;
|
||||
// Guard against API returning zero/null thresholds — fall back to spec defaults
|
||||
this.xtThreshold = {
|
||||
good: (res.xtThreshold?.good > 0) ? res.xtThreshold.good : 2.0,
|
||||
monitor: (res.xtThreshold?.monitor > 0) ? res.xtThreshold.monitor : 4.0,
|
||||
};
|
||||
this.xtSampleSize = res.sampleSize;
|
||||
this.altValue = res.avgSprayAltitudeMeters;
|
||||
this.altThreshold = {
|
||||
target: (res.altThreshold?.target > 0) ? res.altThreshold.target : 3.0,
|
||||
goodDelta: (res.altThreshold?.goodDelta > 0) ? res.altThreshold.goodDelta : 0.5,
|
||||
monitorDelta: (res.altThreshold?.monitorDelta > 0) ? res.altThreshold.monitorDelta : 1.0,
|
||||
};
|
||||
this.altSource = res.altitudeSource;
|
||||
this.altHasData = res.hasAltitudeData;
|
||||
this.altSampleSize = res.sampleSize;
|
||||
this.performanceLoading = false;
|
||||
this.cdRef?.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.performanceLoading = false;
|
||||
this.hasPerformanceError = true;
|
||||
this.msgSvc.addFailedMsg($localize`:@@errorLoadingPerformance:Failed to load performance data. Please refresh the page.`);
|
||||
this.cdRef?.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private formatPct(pct: number): string {
|
||||
if (pct == null || isNaN(pct) || pct === 0) { return '= no change'; }
|
||||
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
||||
}
|
||||
|
||||
onTrendRangeChange(selection: DateRangeSelection): void {
|
||||
if (!selection?.startDate || !selection?.endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = DateUtils.startOfDay(selection.startDate);
|
||||
const endDate = DateUtils.startOfDay(selection.endDate);
|
||||
const [normalizedStart, normalizedEnd] = startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
|
||||
|
||||
// Store range start so drill-down can map bar index → calendar date.
|
||||
this.trendRangeStart = normalizedStart;
|
||||
// Changing the date range resets any active single-day filter.
|
||||
this.selectedIndex = null;
|
||||
this.selectedDayLabel = null;
|
||||
this.selectedDayDate = undefined;
|
||||
|
||||
this.trendLoading = true;
|
||||
this.hasTrendError = false;
|
||||
this.cdRef?.markForCheck();
|
||||
|
||||
this.pilotDashboardService.getTrend(
|
||||
DateUtils.toIsoDate(normalizedStart),
|
||||
DateUtils.toIsoDate(normalizedEnd)
|
||||
).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (res: PilotTrendResponse) => {
|
||||
this.trendData = res.labels.map((label, i) => ({ day: label, value: res.hoursFlown[i] }));
|
||||
this.hectaresTrendData = res.labels.map((label, i) => ({ day: label, value: res.hectaresPerDay[i] }));
|
||||
this.trendLoading = false;
|
||||
this.cdRef?.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.hasTrendError = true;
|
||||
this.trendLoading = false;
|
||||
this.cdRef?.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackByIndex(index: number): number {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
/// Shared styles for all 3-band performance indicator cards (XT error, altitude, etc.).
|
||||
/// Usage: @include indicator-card($badge-row-margin, $value-row-margin)
|
||||
///
|
||||
/// @param $badge-row-margin margin-bottom on .band-badge-row (default 0.75rem)
|
||||
/// @param $value-row-margin margin-bottom on .value-row (default 0.75rem)
|
||||
|
||||
@mixin indicator-card($badge-row-margin: 0.75rem, $value-row-margin: 0.75rem) {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #1a211c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: $value-row-margin;
|
||||
|
||||
.value-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1a211c;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.value-unit {
|
||||
font-size: 1rem;
|
||||
color: #6b7680;
|
||||
}
|
||||
}
|
||||
|
||||
.band-badge-row {
|
||||
margin-bottom: $badge-row-margin;
|
||||
}
|
||||
|
||||
.band-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 99px;
|
||||
|
||||
&.badge-good { background: #e8f5e9; color: #2e7d32; }
|
||||
&.badge-monitor { background: #fff8e1; color: #f57f17; }
|
||||
&.badge-poor { background: #ffebee; color: #c62828; }
|
||||
}
|
||||
|
||||
.threshold-labels {
|
||||
position: relative;
|
||||
height: 1.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.tl-label {
|
||||
position: absolute;
|
||||
font-size: 0.72rem;
|
||||
color: #9e9e9e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-start { left: 0; }
|
||||
.tl-mid { transform: translateX(-50%); }
|
||||
.tl-end { right: 0; left: auto; transform: none; }
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.legend-item {
|
||||
font-weight: 500;
|
||||
color: #6b7680;
|
||||
}
|
||||
|
||||
.legend-sep { color: #ddd; }
|
||||
}
|
||||
}
|
||||
193
Development/client/src/app/dashboard/utils/chart-builders.ts
Normal file
193
Development/client/src/app/dashboard/utils/chart-builders.ts
Normal file
@ -0,0 +1,193 @@
|
||||
export interface TrendDataPoint {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
chartData: any;
|
||||
chartOptions: any;
|
||||
}
|
||||
|
||||
interface DatasetOverrides {
|
||||
label: string;
|
||||
backgroundColor: string | string[];
|
||||
datalabels?: object;
|
||||
/** Decimal places shown in the tooltip. Defaults to 0 (integer). Use 1 for continuous values like hours. */
|
||||
tooltipDecimals?: number;
|
||||
}
|
||||
|
||||
interface YAxisTicks {
|
||||
min: number;
|
||||
max: number;
|
||||
stepSize: number;
|
||||
}
|
||||
|
||||
export class ChartBuilderUtils {
|
||||
private static readonly GREEN_DARK = '#2E7D32';
|
||||
private static readonly GRID_LINE = '#F0F0F0';
|
||||
private static readonly AXIS_LABEL = '#3a4450';
|
||||
private static readonly DATA_LABEL = '#222222';
|
||||
|
||||
private static readonly SHARED_X_AXIS = [
|
||||
{ gridLines: { display: false }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL } }
|
||||
];
|
||||
|
||||
private static buildYAxis(ticks: YAxisTicks) {
|
||||
return [{ gridLines: { color: ChartBuilderUtils.GRID_LINE }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL, beginAtZero: true, ...ticks } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the dataset has a high-variance distribution: the maximum
|
||||
* value is more than 5× the median of non-zero values. Used to detect when
|
||||
* a single outlier would compress the remaining bars into near-invisibility.
|
||||
*/
|
||||
private static isHighVariance(values: number[]): boolean {
|
||||
const nonZero = values.filter(v => v > 0);
|
||||
if (nonZero.length < 2) { return false; }
|
||||
const sorted = [...nonZero].sort((a, b) => a - b);
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
return Math.max(...nonZero) > 5 * median;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every value that is NOT an outlier, using the same 5× median threshold
|
||||
* as isHighVariance. Unlike a simple slice(0,-1), this correctly handles multiple
|
||||
* outliers so the Y-axis scale accommodates every non-outlier bar.
|
||||
*/
|
||||
private static inlierValues(values: number[]): number[] {
|
||||
const nonZero = values.filter(v => v > 0);
|
||||
if (nonZero.length < 2) { return values; }
|
||||
const sorted = [...nonZero].sort((a, b) => a - b);
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
const threshold = 5 * median;
|
||||
return values.filter(v => v <= threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a nice Y-axis scale from the actual data values.
|
||||
* `preferredMax` acts as both the zero-data fallback and a minimum floor —
|
||||
* the computed max will never be less than `preferredMax`.
|
||||
* `scaleValues` overrides which values are used for the scale calculation;
|
||||
* pass a subset (e.g. values without the outlier) to prevent a single spike
|
||||
* from collapsing the rest of the bars.
|
||||
*/
|
||||
private static niceYTicks(values: number[], preferredMax: number, scaleValues?: number[]): YAxisTicks {
|
||||
const ref = scaleValues ?? values;
|
||||
const dataMax = ref.length > 0 ? Math.max(...ref) : 0;
|
||||
// Use whichever is larger: data-driven headroom or the preferred floor
|
||||
const raw = Math.max(dataMax * 1.2, preferredMax);
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5)));
|
||||
const stepSize = Math.ceil((raw / 5) / magnitude) * magnitude;
|
||||
const max = stepSize * 5;
|
||||
return { min: 0, max, stepSize };
|
||||
}
|
||||
|
||||
private static buildBaseChartConfig(
|
||||
data: TrendDataPoint[],
|
||||
dataset: DatasetOverrides,
|
||||
yAxisTicks: YAxisTicks,
|
||||
tooltipUnit: string,
|
||||
plugins?: object
|
||||
): ChartConfig {
|
||||
const values = data.map(d => d.value);
|
||||
return {
|
||||
chartData: {
|
||||
labels: data.map(d => d.day),
|
||||
datasets: [{
|
||||
label: dataset.label,
|
||||
data: values,
|
||||
fill: false,
|
||||
borderColor: ChartBuilderUtils.GREEN_DARK,
|
||||
backgroundColor: dataset.backgroundColor,
|
||||
pointBackgroundColor: ChartBuilderUtils.GREEN_DARK,
|
||||
pointRadius: 4,
|
||||
tension: 0.3,
|
||||
...(dataset.datalabels ? { datalabels: dataset.datalabels } : {})
|
||||
}]
|
||||
},
|
||||
chartOptions: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
legend: { display: false },
|
||||
...(plugins ? { plugins } : {}),
|
||||
scales: {
|
||||
xAxes: ChartBuilderUtils.SHARED_X_AXIS,
|
||||
yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks)
|
||||
},
|
||||
tooltips: {
|
||||
// Use the raw dataset value instead of item.yLabel — yLabel reflects the
|
||||
// bar's rendered position which is clamped to the axis max when a bar is
|
||||
// truncated. dataset.data[index] always holds the original value.
|
||||
callbacks: {
|
||||
label: (item: any, data: any) => {
|
||||
const raw = data?.datasets?.[item.datasetIndex]?.data?.[item.index] ?? item.yLabel;
|
||||
return ` ${Number(raw).toFixed(dataset.tooltipDecimals ?? 0)} ${tooltipUnit}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static interpolateGreenByValue(values: number[]): string[] {
|
||||
const light = { r: 165, g: 214, b: 167 };
|
||||
const dark = { r: 46, g: 125, b: 50 };
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return values.map(val => {
|
||||
const t = (max === min) ? 0 : (val - min) / (max - min);
|
||||
return `rgb(${Math.round(light.r + (dark.r - light.r) * t)},${Math.round(light.g + (dark.g - light.g) * t)},${Math.round(light.b + (dark.b - light.b) * t)})`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a per-index color array for drill-down selection highlighting.
|
||||
* The bar/point at `selectedIndex` receives `selectedColor`; all others get `dimmedColor`.
|
||||
*/
|
||||
static buildSelectionColors(
|
||||
count: number,
|
||||
selectedIndex: number,
|
||||
selectedColor: string,
|
||||
dimmedColor: string
|
||||
): string[] {
|
||||
return Array.from({ length: count }, (_, i) => i === selectedIndex ? selectedColor : dimmedColor);
|
||||
}
|
||||
|
||||
static hoursChart(data: TrendDataPoint[]): ChartConfig {
|
||||
const values = data.map(d => d.value);
|
||||
return ChartBuilderUtils.buildBaseChartConfig(
|
||||
data,
|
||||
{ label: $localize`:Hours flown chart dataset label@@hoursChartLabel:Hours Flown`, backgroundColor: ChartBuilderUtils.GREEN_DARK, tooltipDecimals: 1 },
|
||||
ChartBuilderUtils.niceYTicks(values, 5),
|
||||
$localize`:Hours unit abbreviation@@unitHrs:hrs`
|
||||
);
|
||||
}
|
||||
|
||||
static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig {
|
||||
const values = data.map(d => d.value);
|
||||
// 500 ha ≈ 1235 ac; keep both unit floors proportional so the empty-headroom
|
||||
// is the same fraction of typical values regardless of unit selection.
|
||||
const preferredMax = areaUnit === 'ac' ? 1200 : 500;
|
||||
const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => Math.round(v) };
|
||||
const datasetLabel = areaUnit === 'ac'
|
||||
? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres Sprayed`
|
||||
: $localize`:Hectares sprayed chart dataset label@@hectaresChartLabel:Hectares Sprayed`;
|
||||
// When one day dwarfs the rest, compute the Y-axis scale from the inlier
|
||||
// values only (≤ 5× median). Unlike slice(0,-1), this handles multiple
|
||||
// outliers so every non-outlier bar fits cleanly within the axis.
|
||||
const scaleValues = ChartBuilderUtils.isHighVariance(values)
|
||||
? ChartBuilderUtils.inlierValues(values)
|
||||
: undefined;
|
||||
const config = ChartBuilderUtils.buildBaseChartConfig(
|
||||
data,
|
||||
{ label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels },
|
||||
ChartBuilderUtils.niceYTicks(values, preferredMax, scaleValues),
|
||||
areaUnit,
|
||||
{ datalabels }
|
||||
);
|
||||
// clampedTop is registered by HectaresChartComponent.ngAfterViewInit() — apply it
|
||||
// here only, not in buildBaseChartConfig, so HoursChartComponent is unaffected.
|
||||
config.chartOptions.tooltips.position = 'clampedTop';
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
/** Shared band classification type used by all performance indicator components. */
|
||||
export type IndicatorBand = 'good' | 'monitor' | 'poor';
|
||||
|
||||
/**
|
||||
* Classifies a numeric value into a band by comparing it against two thresholds.
|
||||
* Shared by all indicator components that use the good → monitor → poor pattern.
|
||||
*
|
||||
* @param value The effective value to classify (absolute deviation, raw error, etc.).
|
||||
* @param goodThreshold Upper boundary of the 'good' band.
|
||||
* @param monitorThreshold Upper boundary of the 'monitor' band (anything above is 'poor').
|
||||
*/
|
||||
export function classifyBand(value: number, goodThreshold: number, monitorThreshold: number): IndicatorBand {
|
||||
if (value <= goodThreshold) { return 'good'; }
|
||||
if (value <= monitorThreshold) { return 'monitor'; }
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display label for a given band.
|
||||
* Centralises the label strings so both indicators stay in sync.
|
||||
*/
|
||||
export function getBandLabel(band: IndicatorBand): string {
|
||||
switch (band) {
|
||||
case 'good': return $localize`:Band label good@@bandGood:Good`;
|
||||
case 'monitor': return $localize`:Band label monitor@@bandMonitor:Monitor`;
|
||||
default: return $localize`:Band label high@@bandHigh:High`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw value to a left-offset percentage for the marker tick on a
|
||||
* band bar, clamped so the tick stays visible at both ends.
|
||||
*
|
||||
* @param value The raw value to position (same unit as maxBar).
|
||||
* @param maxBar The total bar range (same unit as value).
|
||||
*/
|
||||
export function clampMarkerPct(value: number, maxBar: number): number {
|
||||
return Math.min(Math.max((value / maxBar) * 100, 2), 98);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a threshold value to a left-offset percentage for scale labels
|
||||
* positioned along a band bar.
|
||||
*
|
||||
* @param threshold The threshold boundary value (same unit as maxBar).
|
||||
* @param maxBar The total bar range (same unit as threshold).
|
||||
*/
|
||||
export function thresholdPct(threshold: number, maxBar: number): number {
|
||||
return (threshold / maxBar) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns explicit pixel-free band widths (as percentages) for a 3-band bar
|
||||
* (green | yellow | red). Using width% instead of flex-grow avoids a browser
|
||||
* bug where flex-grow values that sum to less than 1 do not fill the container.
|
||||
*
|
||||
* @param goodBoundary The value at the green→yellow boundary.
|
||||
* @param monitorBoundary The value at the yellow→red boundary.
|
||||
* @param maxBar The total bar range.
|
||||
*/
|
||||
export function bandWidths(goodBoundary: number, monitorBoundary: number, maxBar: number)
|
||||
: { green: number; yellow: number; red: number } {
|
||||
const green = (goodBoundary / maxBar) * 100;
|
||||
const yellow = ((monitorBoundary - goodBoundary) / maxBar) * 100;
|
||||
const red = 100 - green - yellow;
|
||||
return { green, yellow, red };
|
||||
}
|
||||
|
||||
/** Shape returned by {@link computeBarState}. */
|
||||
export interface BarState {
|
||||
markerPct: number;
|
||||
goodPct: number;
|
||||
monitorPct: number;
|
||||
bandWidths: { green: number; yellow: number; red: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes all bar-rendering values for a 3-band indicator bar in one call.
|
||||
* Both indicator components share the same bar structure; they differ only in
|
||||
* how they derive `goodBoundary`, `monitorBoundary`, and `effectiveValue`.
|
||||
*
|
||||
* @param goodBoundary Upper edge of the green band (e.g. goodDelta, threshold.good).
|
||||
* @param monitorBoundary Upper edge of the yellow band (e.g. monitorDelta, threshold.monitor).
|
||||
* @param effectiveValue Value to position the marker (raw value or absolute deviation).
|
||||
*/
|
||||
export function computeBarState(
|
||||
goodBoundary: number,
|
||||
monitorBoundary: number,
|
||||
effectiveValue: number,
|
||||
): BarState {
|
||||
const safeGood = (goodBoundary > 0 && isFinite(goodBoundary)) ? goodBoundary : 1;
|
||||
const safeMonitor = (monitorBoundary > 0 && isFinite(monitorBoundary)) ? monitorBoundary : 2;
|
||||
const maxBar = safeMonitor + safeGood;
|
||||
return {
|
||||
markerPct: clampMarkerPct(effectiveValue, maxBar),
|
||||
goodPct: thresholdPct(safeGood, maxBar),
|
||||
monitorPct: thresholdPct(safeMonitor, maxBar),
|
||||
bandWidths: bandWidths(safeGood, safeMonitor, maxBar),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import { JobStatus } from '@app/shared/global';
|
||||
|
||||
export interface PilotKpiResponse {
|
||||
assignedJobs: number;
|
||||
assignedHectares: number;
|
||||
sprayedToday: number;
|
||||
flightHoursToday: number;
|
||||
operations: PilotOperationsResponse;
|
||||
historical: {
|
||||
jobs: PilotHistoricalMetric;
|
||||
hectares: PilotHistoricalMetric;
|
||||
flightHours: PilotHistoricalMetric;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PilotHistoricalMetric {
|
||||
year: number;
|
||||
month: number;
|
||||
week: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
|
||||
export interface PilotSummaryResponse {
|
||||
today: PilotSummaryMetrics;
|
||||
yesterday: PilotSummaryMetrics;
|
||||
deltas: PilotSummaryDeltas;
|
||||
}
|
||||
|
||||
export interface PilotSummaryMetrics {
|
||||
hectares: number;
|
||||
flightHours: number;
|
||||
haPerHour: number;
|
||||
avgSpeedKmh: number;
|
||||
sprayVolumeLiters: number;
|
||||
}
|
||||
|
||||
|
||||
export interface PilotSummaryDeltas {
|
||||
hectaresPct: number;
|
||||
flightHoursPct: number;
|
||||
haPerHourPct: number;
|
||||
avgSpeedPct: number;
|
||||
sprayVolumePct: number;
|
||||
}
|
||||
|
||||
export interface PilotOperationsResponse {
|
||||
distanceKm: number;
|
||||
sprayVolumeLiters: number;
|
||||
}
|
||||
|
||||
export interface PilotActiveJobsResponse {
|
||||
jobs: PilotActiveJob[];
|
||||
}
|
||||
|
||||
export interface PilotActiveJob {
|
||||
jobId: number;
|
||||
name: string;
|
||||
clientName: string;
|
||||
aircraftReg: string;
|
||||
status: JobStatus;
|
||||
displayStatus: string;
|
||||
haTotal: number;
|
||||
haSprayed: number;
|
||||
progressPct: number;
|
||||
volumeAppliedLiters: number;
|
||||
}
|
||||
|
||||
/** Trend chart response for the default week view or a user-selected date range. */
|
||||
export interface PilotTrendResponse {
|
||||
labels: string[];
|
||||
hoursFlown: number[];
|
||||
hectaresPerDay: number[];
|
||||
}
|
||||
|
||||
/** Performance response aggregated from the pilot's recent application files. */
|
||||
export interface PilotPerformanceResponse {
|
||||
avgXtErrorMeters: number;
|
||||
xtThreshold: PilotXtThreshold;
|
||||
avgSprayAltitudeMeters: number | null;
|
||||
altitudeSource: 'sprayHeight' | 'radarAlt' | 'gpsAlt' | 'none';
|
||||
altThreshold: PilotAltitudeThreshold;
|
||||
sampleSize: number;
|
||||
hasAltitudeData: boolean;
|
||||
}
|
||||
|
||||
export interface PilotXtThreshold {
|
||||
good: number;
|
||||
monitor: number;
|
||||
}
|
||||
|
||||
export interface PilotAltitudeThreshold {
|
||||
target: number;
|
||||
goodDelta: number;
|
||||
monitorDelta: number;
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { DateUtils } from '../../shared/utils';
|
||||
import {
|
||||
PilotKpiResponse,
|
||||
PilotSummaryResponse,
|
||||
PilotActiveJobsResponse,
|
||||
PilotTrendResponse,
|
||||
PilotPerformanceResponse
|
||||
} from '../models/pilot-dashboard.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PilotDashboardService {
|
||||
private readonly baseUrl = '/dashboard/pilot';
|
||||
private readonly tz = DateUtils.browserTz();
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private tzParams(extra?: Record<string, string>): HttpParams {
|
||||
let params = new HttpParams().set('tz', this.tz);
|
||||
if (extra) {
|
||||
Object.keys(extra).forEach(key => params = params.set(key, extra[key]));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
getKpi(selectedDate?: string): Observable<PilotKpiResponse> {
|
||||
return this.http.get<PilotKpiResponse>(`${this.baseUrl}/kpi`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getSummary(selectedDate?: string): Observable<PilotSummaryResponse> {
|
||||
return this.http.get<PilotSummaryResponse>(`${this.baseUrl}/summary`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getActiveJobs(selectedDate?: string): Observable<PilotActiveJobsResponse> {
|
||||
return this.http.get<PilotActiveJobsResponse>(`${this.baseUrl}/activeJobs`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getTrend(startDate: string, endDate: string): Observable<PilotTrendResponse> {
|
||||
return this.http.get<PilotTrendResponse>(`${this.baseUrl}/trend`, { params: this.tzParams({ startDate, endDate }) }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getPerformance(selectedDate?: string): Observable<PilotPerformanceResponse> {
|
||||
return this.http.get<PilotPerformanceResponse>(`${this.baseUrl}/performance`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
// TODO: Add better error handling (logging, user feedback, etc.)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
{ label: globals.statusReady, value: jobListStatus.READY },
|
||||
{ label: globals.statusDownloaded, value: jobListStatus.DOWNLOAD },
|
||||
{ label: globals.statusSprayed, value: jobListStatus.SPRAY },
|
||||
{ label: globals.statusCompleted, value: jobListStatus.COMPLETED },
|
||||
{ label: globals.statusInvoiced, value: jobListStatus.INVOICED },
|
||||
];
|
||||
|
||||
@ -243,6 +244,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
1: jobListStatus.READY,
|
||||
2: jobListStatus.DOWNLOAD,
|
||||
3: jobListStatus.SPRAY,
|
||||
4: jobListStatus.COMPLETED,
|
||||
5: jobListStatus.INVOICED,
|
||||
[jobInvoiceStatus.INVOICED]: jobListStatus.INVOICED
|
||||
};
|
||||
this.statusFilter = statusMap[status] ?? statusMap[invoiced] ?? jobListStatus.ALL;
|
||||
@ -255,6 +258,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
[jobListStatus.READY]: 1,
|
||||
[jobListStatus.DOWNLOAD]: 2,
|
||||
[jobListStatus.SPRAY]: 3,
|
||||
[jobListStatus.COMPLETED]: 4,
|
||||
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
|
||||
};
|
||||
|
||||
@ -465,6 +469,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.dt.filter('', 'invoiceStatus', 'contains');
|
||||
this.statusFilter = jobListStatus.SPRAY;
|
||||
break;
|
||||
case jobListStatus.COMPLETED:
|
||||
this.dt.filter(4, 'status', 'equals');
|
||||
this.dt.filter('', 'invoiceStatus', 'contains');
|
||||
this.statusFilter = jobListStatus.COMPLETED;
|
||||
break;
|
||||
case jobListStatus.INVOICED:
|
||||
this.dt.filter(null, 'status', 'equals');
|
||||
this.dt.filter(jobInvoiceStatus.INVOICED, 'invoiceStatus', 'contains');
|
||||
|
||||
@ -4,6 +4,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
import { PaginatorModule } from 'primeng/paginator';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
@ -49,7 +50,7 @@ import { BillingAddressListComponent } from './billing-address-list/billing-addr
|
||||
imports: [
|
||||
CommonModule, AppSharedModule, ProfileRoutingModule,
|
||||
TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, ToolbarModule, ProgressBarModule, TabViewModule,
|
||||
CalendarModule,
|
||||
CalendarModule, InputSwitchModule,
|
||||
StoreModule.forFeature(FEATURE_KEY, profileReducers),
|
||||
EffectsModule.forFeature([ProfileEffects, PaymentEffects, UsageEffects]),
|
||||
],
|
||||
|
||||
@ -4,6 +4,13 @@ import { AuthService } from '../domain/services/auth.service';
|
||||
|
||||
export enum MsgType { Success = 'success', Info = 'info', Warn = 'warn', Error = 'error' };
|
||||
|
||||
const TOAST_SUMMARIES: Record<MsgType, string> = {
|
||||
[MsgType.Success]: $localize`:@@toastSummarySuccess:Success`,
|
||||
[MsgType.Info]: $localize`:@@toastSummaryInfo:Info`,
|
||||
[MsgType.Warn]: $localize`:@@toastSummaryWarn:Warn`,
|
||||
[MsgType.Error]: $localize`:@@toastSummaryError:Error`,
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppMessageService {
|
||||
|
||||
@ -28,7 +35,7 @@ export class AppMessageService {
|
||||
if (clearPrevious)
|
||||
this.clear();
|
||||
if (!this.authSvc.loggedIn) return;
|
||||
this.msgSvc.add({ severity: type, summary: type.charAt(0).toUpperCase() + type.slice(1), detail: msg });
|
||||
this.msgSvc.add({ severity: type, summary: TOAST_SUMMARIES[type as MsgType] ?? type, detail: msg });
|
||||
}
|
||||
|
||||
addMsgs(msgs: Message[]) {
|
||||
|
||||
@ -76,6 +76,7 @@ import { BadgeComponent } from './badge/badge.component';
|
||||
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
||||
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
||||
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
||||
import { DateRangeControlComponent } from './date-range-control/date-range-control.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -90,6 +91,7 @@ import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-
|
||||
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
|
||||
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||
DateRangeControlComponent,
|
||||
|
||||
],
|
||||
exports: [
|
||||
@ -101,7 +103,8 @@ import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-
|
||||
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||
DebounceDirective, UnitIdUniqueDirective,
|
||||
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
||||
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent
|
||||
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||
DateRangeControlComponent
|
||||
],
|
||||
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
|
||||
})
|
||||
|
||||
@ -72,6 +72,10 @@ export class BaseComp implements OnDestroy {
|
||||
return this.authSvc != null && this.authSvc.isApplicator;
|
||||
}
|
||||
|
||||
get isUS(): boolean {
|
||||
return this.settings?.measureUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description cdRef must set from the leaf child component, using this because of Angular apply ChangeDetectorRef instance to a specific compoment instance
|
||||
*/
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
<div class="date-range-control-card">
|
||||
<label class="date-range-label" i18n="Date range label@@dateRangeLabel">Date Range</label>
|
||||
<p-calendar
|
||||
[formControl]="dateRangeControl"
|
||||
[selectionMode]="'range'"
|
||||
[readonlyInput]="true"
|
||||
[showIcon]="true"
|
||||
[maxDate]="maxDate"
|
||||
[minDate]="minDate"
|
||||
[numberOfMonths]="1"
|
||||
[dateFormat]="'dd M yy'"
|
||||
[showButtonBar]="true"
|
||||
[showWeek]="true"
|
||||
panelStyleClass="week-picker"
|
||||
appendTo="body"
|
||||
(onSelect)="onRangeSelected(dateRangeControl.value)"
|
||||
(onMonthChange)="onMonthChange($event)"
|
||||
></p-calendar>
|
||||
</div>
|
||||
@ -0,0 +1,17 @@
|
||||
.date-range-control-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px 0 rgba(44, 62, 80, 0.06), 0 1.5px 4px 0 rgba(44, 62, 80, 0.03);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-range-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1a211c;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
import { Component, EventEmitter, OnInit, OnDestroy, AfterViewInit, Output } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { DateUtils } from '../utils';
|
||||
|
||||
export interface DateRangeSelection {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'agm-date-range-control',
|
||||
templateUrl: './date-range-control.component.html',
|
||||
styleUrls: ['./date-range-control.component.scss']
|
||||
})
|
||||
export class DateRangeControlComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@Output() rangeChange = new EventEmitter<DateRangeSelection>();
|
||||
|
||||
readonly maxRangeDays = 90;
|
||||
readonly maxDate = DateUtils.startOfDay(new Date());
|
||||
readonly minDate = DateUtils.addDays(this.maxDate, -(this.maxRangeDays - 1));
|
||||
|
||||
readonly dateRangeControl = new FormControl([]);
|
||||
|
||||
// Tracks which month/year is currently displayed in the calendar panel.
|
||||
// Used to resolve day-cell numbers into full Date objects on week-row click.
|
||||
private viewMonth = new Date().getMonth(); // 0-based
|
||||
private viewYear = new Date().getFullYear();
|
||||
|
||||
// Bound document-level click handler — kept as a reference so it can be removed in ngOnDestroy.
|
||||
private readonly boundWeekClick = (e: MouseEvent) => this.onCalendarClick(e);
|
||||
|
||||
ngOnInit(): void {
|
||||
const defaultRange = this.getCurrentWeekRange();
|
||||
this.dateRangeControl.setValue(defaultRange);
|
||||
this.emitIfRangeComplete(defaultRange);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// The calendar panel is appended to <body> (appendTo="body"), so the click
|
||||
// handler must live on document — not on the component's host element.
|
||||
document.addEventListener('click', this.boundWeekClick);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
document.removeEventListener('click', this.boundWeekClick);
|
||||
}
|
||||
|
||||
/** Keeps viewMonth/viewYear in sync when the user navigates the calendar. */
|
||||
onMonthChange(event: { month: number; year: number }): void {
|
||||
this.viewMonth = event.month - 1; // PrimeNG emits 1-based month
|
||||
this.viewYear = event.year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicks anywhere inside the calendar panel.
|
||||
* When the click lands on a week-number cell, the entire displayed week row
|
||||
* is selected as the date range (clamped to maxDate / minDate).
|
||||
*/
|
||||
onCalendarClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
// Guard: only act on clicks inside a week-picker calendar panel.
|
||||
// appendTo="body" renders multiple panels in <body>; other calendars must be ignored.
|
||||
if (!target.closest('.ui-datepicker.week-picker')) { return; }
|
||||
const weekCell = target.closest('.ui-datepicker-weeknumber') as HTMLElement | null;
|
||||
if (!weekCell) { return; }
|
||||
|
||||
const row = weekCell.closest('tr') as HTMLTableRowElement | null;
|
||||
if (!row) { return; }
|
||||
|
||||
// All TD cells in the row except the week-number cell itself
|
||||
const dayCells = Array.from(
|
||||
row.querySelectorAll('td:not(.ui-datepicker-weeknumber)')
|
||||
) as HTMLElement[];
|
||||
|
||||
const dates: Date[] = [];
|
||||
// Track transition: prev-month overflow → current month → next-month overflow
|
||||
let phase: 'prev' | 'current' | 'next' = 'prev';
|
||||
|
||||
for (const cell of dayCells) {
|
||||
const isOther = cell.classList.contains('ui-datepicker-other-month');
|
||||
|
||||
// Detect transition from prev overflow to current month
|
||||
if (!isOther && phase === 'prev') { phase = 'current'; }
|
||||
// Detect transition from current month to next overflow
|
||||
if (isOther && phase === 'current') { phase = 'next'; }
|
||||
|
||||
const anchor = cell.querySelector('a, span') as HTMLElement | null;
|
||||
if (!anchor) { continue; }
|
||||
const dayNum = parseInt(anchor.textContent?.trim() ?? '', 10);
|
||||
if (isNaN(dayNum)) { continue; }
|
||||
|
||||
let month = this.viewMonth;
|
||||
let year = this.viewYear;
|
||||
|
||||
if (phase === 'prev') {
|
||||
month--;
|
||||
if (month < 0) { month = 11; year--; }
|
||||
} else if (phase === 'next') {
|
||||
month++;
|
||||
if (month > 11) { month = 0; year++; }
|
||||
}
|
||||
|
||||
dates.push(new Date(year, month, dayNum));
|
||||
}
|
||||
|
||||
if (dates.length === 0) { return; }
|
||||
|
||||
const today = this.maxDate;
|
||||
let startDate = DateUtils.startOfDay(dates[0]);
|
||||
let endDate = DateUtils.startOfDay(dates[dates.length - 1]);
|
||||
|
||||
// Clamp to allowed bounds
|
||||
if (startDate < this.minDate) { startDate = this.minDate; }
|
||||
if (endDate > today) { endDate = today; }
|
||||
if (startDate > today) { return; } // entire week is in the future
|
||||
|
||||
const range = [startDate, endDate];
|
||||
this.dateRangeControl.setValue(range);
|
||||
this.onRangeSelected(range);
|
||||
}
|
||||
|
||||
onRangeSelected(range: Date[]): void {
|
||||
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = DateUtils.startOfDay(range[0]);
|
||||
const selectedEndDate = DateUtils.startOfDay(range[1]);
|
||||
const maxEndDate = DateUtils.addDays(startDate, this.maxRangeDays - 1);
|
||||
const endDate = selectedEndDate > maxEndDate ? maxEndDate : selectedEndDate;
|
||||
|
||||
if (endDate.getTime() !== selectedEndDate.getTime()) {
|
||||
const clampedRange = [startDate, endDate];
|
||||
this.dateRangeControl.setValue(clampedRange, { emitEvent: false });
|
||||
this.emitIfRangeComplete(clampedRange);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitIfRangeComplete([startDate, endDate]);
|
||||
}
|
||||
|
||||
private emitIfRangeComplete(range: Date[]): void {
|
||||
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rangeChange.emit({
|
||||
startDate: DateUtils.startOfDay(range[0]),
|
||||
endDate: DateUtils.startOfDay(range[1])
|
||||
});
|
||||
}
|
||||
|
||||
private getCurrentWeekRange(): Date[] {
|
||||
const today = DateUtils.startOfDay(new Date());
|
||||
const day = today.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
const daysSinceMonday = day === 0 ? 6 : day - 1;
|
||||
const monday = DateUtils.addDays(today, -daysSinceMonday);
|
||||
const sunday = DateUtils.addDays(monday, 6);
|
||||
const endDate = sunday > today ? today : sunday;
|
||||
return [monday, endDate];
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { FitBoundsOptions } from 'leaflet';
|
||||
/** The user types. This is used to refer to user roles regarding to different access permissions of app/module functionality as well */
|
||||
export enum RoleIds { ADMIN = "0", APP = "1", APP_ADM = "2", CLIENT = "3", OFFICER = "4", PILOT = "5", INSPECTOR = "6", DEVICE = "9", VENDOR = "10", PARTNER = "20", PARTNER_SYSTEM_USER = "21" };
|
||||
|
||||
export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 };
|
||||
export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, COMPLETED = 4, INVOICED = 5, ARCHIVED = 9 };
|
||||
export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ };
|
||||
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE };
|
||||
export enum PANE {
|
||||
@ -524,6 +524,7 @@ export const globals = Object.freeze({
|
||||
statusUploaded: $localize`:@@uploaded:Uploaded`,
|
||||
statusError: $localize`:@@error:Error`,
|
||||
statusSprayed: $localize`:@@sprayed:Sprayed`,
|
||||
statusCompleted: $localize`:@@completed:Completed`,
|
||||
statusArchived: $localize`:@@archived:Archived`,
|
||||
statusInvoiced: $localize`:@@invoiced:Invoiced`,
|
||||
|
||||
@ -840,6 +841,8 @@ export const JobStatuses: any = Object.freeze({
|
||||
[JobStatus.READY]: globals.statusReady,
|
||||
[JobStatus.DOWNLOADED]: globals.statusDownloaded,
|
||||
[JobStatus.SPRAYED]: globals.statusSprayed,
|
||||
[JobStatus.COMPLETED]: globals.statusCompleted,
|
||||
[JobStatus.INVOICED]: globals.statusInvoiced,
|
||||
[JobStatus.ARCHIVED]: globals.statusArchived,
|
||||
});
|
||||
|
||||
@ -936,6 +939,8 @@ export const GC = Object.freeze({
|
||||
{ label: globals.statusReady, value: JobStatus.READY },
|
||||
{ label: globals.statusDownloaded, value: JobStatus.DOWNLOADED },
|
||||
{ label: globals.statusSprayed, value: JobStatus.SPRAYED },
|
||||
{ label: globals.statusCompleted, value: JobStatus.COMPLETED },
|
||||
{ label: globals.statusInvoiced, value: JobStatus.INVOICED },
|
||||
{ label: globals.statusArchived, value: JobStatus.ARCHIVED }
|
||||
],
|
||||
selSprZoneColors: [
|
||||
@ -984,6 +989,7 @@ export const jobListStatus = Object.freeze({
|
||||
READY: 'ready',
|
||||
DOWNLOAD: 'download',
|
||||
SPRAY: 'spray',
|
||||
COMPLETED: 'completed',
|
||||
INVOICED: 'invoiced'
|
||||
});
|
||||
|
||||
|
||||
162
Development/client/src/app/shared/mock/pilot-dashboard-mock.ts
Normal file
162
Development/client/src/app/shared/mock/pilot-dashboard-mock.ts
Normal file
@ -0,0 +1,162 @@
|
||||
export const MOCK_KPI_DATA = [
|
||||
{ label: $localize`:KPI label@@kpiAssignedJobs:Assigned Jobs`, value: 10, unit: '', historical: { day: 8, week: 22, month: 68, year: 540 } },
|
||||
{ label: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`, value: 2320, unit: 'ha', historical: { day: 1990, week: 8200, month: 24500, year: 21000 } },
|
||||
{ label: $localize`:KPI label@@kpiHectaresToday:Hectares Sprayed Today`, value: 520, unit: 'ha', historical: { day: 990, week: 2150, month: 8743, year: 24580 } },
|
||||
{ label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`, value: 4.3, unit: 'hrs', historical: { day: 8.1, week: 25.4, month: 98.6, year: 412 } }
|
||||
];
|
||||
|
||||
export const MOCK_SUMMARY_DATA = [
|
||||
{ label: $localize`:Summary label@@summaryHectares:Hectares`, value: 520, unit: 'ha', change: '-30%' },
|
||||
{ label: $localize`:Summary label@@summaryFlightHours:Flight Hours`, value: 4.3, unit: 'hrs', change: '-25%' },
|
||||
{ label: 'ha/hr', value: 121, unit: '', change: '\u2248 122 yesterday' },
|
||||
{ label: $localize`:Summary label@@summaryAvgSpeed:Avg Speed`, value: 136, unit: 'km/h', change: '= no change' },
|
||||
{ label: $localize`:Summary label@@summarySprayVolume:Spray Volume`, value: 8350, unit: 'L', change: '+5%' }
|
||||
];
|
||||
|
||||
export const MOCK_OPERATIONS_TODAY = {
|
||||
distanceKm: 42.7,
|
||||
sprayVolumeL: 8350
|
||||
};
|
||||
|
||||
export const MOCK_ACTIVE_JOBS = [
|
||||
{
|
||||
id: 1,
|
||||
tail: 'PT-LNUP550',
|
||||
field: 'Field A',
|
||||
client: 'Titanium Ag',
|
||||
status: 'IN PROGRESS',
|
||||
sprayed: 320,
|
||||
total: 500,
|
||||
volume: 3200
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tail: 'PT-AAIR600',
|
||||
field: 'Paddock 6',
|
||||
client: 'Farmer Mexico Inc',
|
||||
status: 'NEW',
|
||||
sprayed: 0,
|
||||
total: 300,
|
||||
volume: 0
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tail: 'PT-PAG555',
|
||||
field: 'North West',
|
||||
client: 'Mapas',
|
||||
status: 'COMPLETED',
|
||||
sprayed: 100,
|
||||
total: 100,
|
||||
volume: 820
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
tail: 'PT-AGT801',
|
||||
field: 'South Block',
|
||||
client: 'Vale Verde',
|
||||
status: 'IN PROGRESS',
|
||||
sprayed: 208,
|
||||
total: 420,
|
||||
volume: 2550
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
tail: 'PT-AGX402',
|
||||
field: 'Field 9',
|
||||
client: 'Aero Rural',
|
||||
status: 'NEW',
|
||||
sprayed: 0,
|
||||
total: 260,
|
||||
volume: 0
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
tail: 'PT-BRZ210',
|
||||
field: 'West Strip',
|
||||
client: 'Agro Titan',
|
||||
status: 'IN PROGRESS',
|
||||
sprayed: 78,
|
||||
total: 140,
|
||||
volume: 640
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
tail: 'PT-AT602A',
|
||||
field: 'Block 12',
|
||||
client: 'Sierra Campo',
|
||||
status: 'IN PROGRESS',
|
||||
sprayed: 180,
|
||||
total: 260,
|
||||
volume: 1730
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
tail: 'PT-FBX900',
|
||||
field: 'North Ridge',
|
||||
client: 'Green Valley',
|
||||
status: 'NEW',
|
||||
sprayed: 0,
|
||||
total: 190,
|
||||
volume: 0
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
tail: 'PT-SLV120',
|
||||
field: 'East Paddock',
|
||||
client: 'Rio Farms',
|
||||
status: 'COMPLETED',
|
||||
sprayed: 310,
|
||||
total: 310,
|
||||
volume: 2480
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
tail: 'PT-VRD045',
|
||||
field: 'Delta Zone',
|
||||
client: 'Campo Largo',
|
||||
status: 'IN PROGRESS',
|
||||
sprayed: 95,
|
||||
total: 350,
|
||||
volume: 760
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_HOURS_TREND_DATA = [
|
||||
{ day: 'Sun', value: 2 },
|
||||
{ day: 'Mon', value: 3 },
|
||||
{ day: 'Tue', value: 4 },
|
||||
{ day: 'Wed', value: 5 },
|
||||
{ day: 'Thu', value: 6 },
|
||||
{ day: 'Fri', value: 5 },
|
||||
{ day: 'Sat', value: 4 }
|
||||
];
|
||||
|
||||
export const MOCK_HECTARES_TREND_DATA = [
|
||||
{ day: 'Sun', value: 136 },
|
||||
{ day: 'Mon', value: 204 },
|
||||
{ day: 'Tue', value: 272 },
|
||||
{ day: 'Wed', value: 340 },
|
||||
{ day: 'Thu', value: 476 },
|
||||
{ day: 'Fri', value: 408 },
|
||||
{ day: 'Sat', value: 340 }
|
||||
];
|
||||
|
||||
export const MOCK_TREND_DATA = [
|
||||
{ day: 'Sun', value: 120 },
|
||||
{ day: 'Mon', value: 180 },
|
||||
{ day: 'Tue', value: 220 },
|
||||
{ day: 'Wed', value: 320 },
|
||||
{ day: 'Thu', value: 400 },
|
||||
{ day: 'Fri', value: 350 },
|
||||
{ day: 'Sat', value: 300 }
|
||||
];
|
||||
|
||||
export const MOCK_PERFORMANCE_DATA = {
|
||||
avgXtErrorMeters: 1.2,
|
||||
xtThreshold: { good: 1.0, monitor: 3.0 },
|
||||
avgSprayAltitudeMeters: 3.505, // 11.5 ft — 0.5 ft below target of 12 ft (Monitor)
|
||||
altitudeSource: 'radarAlt' as const,
|
||||
altThreshold: { target: 3.658, goodDelta: 0.152, monitorDelta: 0.457 }, // 12 ft, ±0.5 ft, ±1.5 ft
|
||||
sampleSize: 1420,
|
||||
hasAltitudeData: true
|
||||
};
|
||||
@ -154,6 +154,51 @@ export class UnitUtils {
|
||||
return !isUS ? value * 3.28084 : value;
|
||||
}
|
||||
|
||||
/** Convert metres to feet without condition */
|
||||
static mToFt(meters: number): number {
|
||||
return meters * 3.28084;
|
||||
}
|
||||
|
||||
/** Return the area unit label string. Short form only. */
|
||||
static areaUnitLabel(isUS: boolean): string {
|
||||
return isUS ? 'ac' : 'ha';
|
||||
}
|
||||
|
||||
/** Convert liters to gallons (US) or keep as liters (metric) */
|
||||
static litersToVolume(liters: number, isUS: boolean): number {
|
||||
return isUS ? liters * 0.264172 : liters;
|
||||
}
|
||||
|
||||
/** Return the volume unit label string */
|
||||
static volumeUnitLabel(isUS: boolean): string {
|
||||
return isUS ? 'gal' : 'L';
|
||||
}
|
||||
|
||||
/** Convert km to miles (US) or keep as km (metric) */
|
||||
static kmToDistance(km: number, isUS: boolean): number {
|
||||
return isUS ? km * 0.621371 : km;
|
||||
}
|
||||
|
||||
/** Return the distance unit label string */
|
||||
static distanceUnitLabel(isUS: boolean): string {
|
||||
return isUS ? 'mi' : 'km';
|
||||
}
|
||||
|
||||
/** Convert km/h to mph (US) or keep as km/h (metric) */
|
||||
static speedToDisplay(kmh: number, isUS: boolean): number {
|
||||
return isUS ? kmh * 0.621371 : kmh;
|
||||
}
|
||||
|
||||
/** Return the speed unit label string */
|
||||
static speedUnitLabel(isUS: boolean): string {
|
||||
return isUS ? 'mph' : 'km/h';
|
||||
}
|
||||
|
||||
/** Return the length/height unit label string (ft for US, m for metric) */
|
||||
static lengthUnitLabel(isUS: boolean): string {
|
||||
return isUS ? 'ft' : 'm';
|
||||
}
|
||||
|
||||
static mpsToKph(mps: number) {
|
||||
return mps * 3.6;
|
||||
}
|
||||
@ -670,6 +715,8 @@ export class ColorUtils {
|
||||
|
||||
export class DateUtils {
|
||||
|
||||
static readonly MS_PER_DAY = 86400000;
|
||||
|
||||
static firstDayOfMonth(date: Date) {
|
||||
return (date && date instanceof Date) ? new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0) : date;
|
||||
}
|
||||
@ -692,6 +739,11 @@ export class DateUtils {
|
||||
return date instanceof Date ? `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}` : '';
|
||||
}
|
||||
|
||||
/** Format a Date as an ISO date string (YYYY-MM-DD) in local time. */
|
||||
static toIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}-${NumUtils.padZero(date.getDate(), 2)}`;
|
||||
}
|
||||
|
||||
static msToTime(ms, tz = undefined) {
|
||||
if (!ms) return "00:00:00.0";
|
||||
let secs, min, hrs;
|
||||
@ -769,6 +821,24 @@ export class DateUtils {
|
||||
}
|
||||
}
|
||||
|
||||
static startOfDay(date: Date): Date {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static addDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(nextDate.getDate() + days);
|
||||
return DateUtils.startOfDay(nextDate);
|
||||
}
|
||||
|
||||
static diffDaysInclusive(startDate: Date, endDate: Date): number {
|
||||
const start = DateUtils.startOfDay(startDate).getTime();
|
||||
const end = DateUtils.startOfDay(endDate).getTime();
|
||||
return Math.floor((end - start) / DateUtils.MS_PER_DAY) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert stored gpsTime (UTC unix timestamp) back to local ISO time with offset
|
||||
* @param {number} gpsTime - Unix timestamp with milliseconds (e.g., 1715443864.590)
|
||||
@ -797,6 +867,10 @@ export class DateUtils {
|
||||
// Format as ISO 8601 local time
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${secs}.${ms}`;
|
||||
}
|
||||
|
||||
static browserTz(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
export class GeoUtil {
|
||||
|
||||
@ -5,6 +5,38 @@
|
||||
<trans-unit id="pleaseWait" datatype="html">
|
||||
<source>Please wait</source>
|
||||
<target state="final">Please wait</target>
|
||||
</trans-unit><trans-unit id="kpiAssignedJobs" datatype="html">
|
||||
<source>Assigned Jobs</source>
|
||||
<target state="final">Assigned Jobs</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiAssignedHectares" datatype="html">
|
||||
<source>Assigned Hectares</source>
|
||||
<target state="final">Assigned Hectares</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiHectaresToday" datatype="html">
|
||||
<source>Hectares Sprayed Today</source>
|
||||
<target state="final">Hectares Sprayed Today</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiFlightHours" datatype="html">
|
||||
<source>Flight Hours Today</source>
|
||||
<target state="final">Flight Hours Today</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="summaryHectares" datatype="html">
|
||||
<source>Hectares</source>
|
||||
<target state="final">Hectares</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryFlightHours" datatype="html">
|
||||
<source>Flight Hours</source>
|
||||
<target state="final">Flight Hours</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryAvgSpeed" datatype="html">
|
||||
<source>Avg Speed</source>
|
||||
<target state="final">Avg Speed</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summarySprayVolume" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Spray Volume</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="totalExTax" datatype="html">
|
||||
<source>Total Excluding Tax</source>
|
||||
<target state="translated">Total Excluding Tax</target>
|
||||
@ -27,6 +59,34 @@
|
||||
<source>Copyright© 2018. AgMission of AG-NAV Inc. All Rights Reserved.<x id="START_PARAGRAPH"/>IN NO EVENT SHALL AG-NAV BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES, INCLUDING LOSS OF USE, DATA, OR PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION EVEN IF AG-NAV HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is provided “AS IS” WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. AG-NAV assumes no responsibility for errors or omissions in the software or documentation available from the agnav.com website.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is in the beta phase. AG-NAV will update AgMission whenever necessary without notice. The user is granted the permission to use the software free of charge until further notice.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.<x id="CLOSE_PARAGRAPH"/></source>
|
||||
<target state="final">Copyright© 2018. AgMission of AG-NAV Inc. All Rights Reserved.<x id="START_PARAGRAPH"/>IN NO EVENT SHALL AG-NAV BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES, INCLUDING LOSS OF USE, DATA, OR PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION EVEN IF AG-NAV HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is provided “AS IS” WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. AG-NAV assumes no responsibility for errors or omissions in the software or documentation available from the agnav.com website.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is in the beta phase. AG-NAV will update AgMission whenever necessary without notice. The user is granted the permission to use the software free of charge until further notice.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.<x id="CLOSE_PARAGRAPH"/></target>
|
||||
<note priority="1" from="description">Dislaimer in Dashboard screen</note>
|
||||
</trans-unit><trans-unit id="hoursChartLabel" datatype="html">
|
||||
<source>Hours Flown</source>
|
||||
<target state="final">Hours Flown</target>
|
||||
<note priority="1" from="description">Hours flown chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHrs" datatype="html">
|
||||
<source>hrs</source>
|
||||
<target state="final">hrs</target>
|
||||
<note priority="1" from="description">Hours unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="hectaresChartLabel" datatype="html">
|
||||
<source>Hectares Sprayed</source>
|
||||
<target state="final">Hectares Sprayed</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHa" datatype="html">
|
||||
<source>ha</source>
|
||||
<target state="final">ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="bandGood" datatype="html">
|
||||
<source>Good</source>
|
||||
<target state="final">Good</target>
|
||||
<note priority="1" from="description">Band label good</note>
|
||||
</trans-unit><trans-unit id="bandMonitor" datatype="html">
|
||||
<source>Monitor</source>
|
||||
<target state="final">Monitor</target>
|
||||
<note priority="1" from="description">Band label monitor</note>
|
||||
</trans-unit><trans-unit id="bandHigh" datatype="html">
|
||||
<source>High</source>
|
||||
<target state="final">High</target>
|
||||
<note priority="1" from="description">Band label high</note>
|
||||
</trans-unit><trans-unit id="promoFree" datatype="html">
|
||||
<source>FREE</source>
|
||||
<target state="final">FREE</target>
|
||||
@ -1738,6 +1798,9 @@
|
||||
<trans-unit id="sprayed" datatype="html">
|
||||
<source>Sprayed</source>
|
||||
<target state="final">Sprayed</target>
|
||||
</trans-unit><trans-unit id="completed" datatype="html">
|
||||
<source>Completed</source>
|
||||
<target state="final">Completed</target>
|
||||
</trans-unit><trans-unit id="archived" datatype="html">
|
||||
<source>Archived</source>
|
||||
<target state="final">Archived</target>
|
||||
@ -2174,6 +2237,142 @@
|
||||
<trans-unit id="contact" datatype="html">
|
||||
<source>Contact</source>
|
||||
<target state="final">Contact</target>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsTitle" datatype="html">
|
||||
<source>Active Jobs</source>
|
||||
<target state="final">Active Jobs</target>
|
||||
<note priority="1" from="description">Active jobs panel heading</note>
|
||||
</trans-unit><trans-unit id="pilotViewAllJobs" datatype="html">
|
||||
<source>View All</source>
|
||||
<target state="final">View All</target>
|
||||
<note priority="1" from="description">View all jobs link</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsError" datatype="html">
|
||||
<source>Failed to load jobs. Please try again.</source>
|
||||
<target state="final">Failed to load jobs. Please try again.</target>
|
||||
<note priority="1" from="description">Active jobs error message</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsEmpty" datatype="html">
|
||||
<source>No jobs assigned</source>
|
||||
<target state="final">No jobs assigned</target>
|
||||
<note priority="1" from="description">Active jobs empty state</note>
|
||||
</trans-unit><trans-unit id="pilotJobClientPrefix" datatype="html">
|
||||
<source>Client: <x id="INTERPOLATION"/></source>
|
||||
<target state="final">Client: <x id="INTERPOLATION"/></target>
|
||||
<note priority="1" from="description">Job client prefix</note>
|
||||
</trans-unit><trans-unit id="pilotLApplied" datatype="html">
|
||||
<source>L applied</source>
|
||||
<target state="final">L applied</target>
|
||||
<note priority="1" from="description">Litres applied label</note>
|
||||
</trans-unit><trans-unit id="pilotJobNotStarted" datatype="html">
|
||||
<source>Not started</source>
|
||||
<target state="final">Not started</target>
|
||||
<note priority="1" from="description">Not started job label</note>
|
||||
</trans-unit><trans-unit id="jobStatusNew" datatype="html">
|
||||
<source>NEW</source>
|
||||
<target state="final">NEW</target>
|
||||
<note priority="1" from="description">Job status new</note>
|
||||
</trans-unit><trans-unit id="jobStatusInProgress" datatype="html">
|
||||
<source>IN PROGRESS</source>
|
||||
<target state="final">IN PROGRESS</target>
|
||||
<note priority="1" from="description">Job status in progress</note>
|
||||
</trans-unit><trans-unit id="jobStatusCompleted" datatype="html">
|
||||
<source>COMPLETED</source>
|
||||
<target state="final">COMPLETED</target>
|
||||
<note priority="1" from="description">Job status completed</note>
|
||||
</trans-unit><trans-unit id="pilotAltTitle" datatype="html">
|
||||
<source>Average Altitude Spraying</source>
|
||||
<target state="final">Average Altitude Spraying</target>
|
||||
<note priority="1" from="description">Altitude indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendTarget" datatype="html">
|
||||
<source>Target ~<x id="INTERPOLATION"/> ft</source>
|
||||
<target state="final">Target ~<x id="INTERPOLATION"/> ft</target>
|
||||
<note priority="1" from="description">Altitude legend target</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendIdeal" datatype="html">
|
||||
<source>±<x id="INTERPOLATION"/> ft ideal</source>
|
||||
<target state="final">±<x id="INTERPOLATION"/> ft ideal</target>
|
||||
<note priority="1" from="description">Altitude legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendHighRisk" datatype="html">
|
||||
<source>> ±<x id="INTERPOLATION"/> ft high risk</source>
|
||||
<target state="final">> ±<x id="INTERPOLATION"/> ft high risk</target>
|
||||
<note priority="1" from="description">Altitude legend high risk</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoData" datatype="html">
|
||||
<source>Altitude data not available</source>
|
||||
<target state="final">Altitude data not available</target>
|
||||
<note priority="1" from="description">Altitude no data message</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoDataHint" datatype="html">
|
||||
<source>(requires Flight Master or radar)</source>
|
||||
<target state="final">(requires Flight Master or radar)</target>
|
||||
<note priority="1" from="description">Altitude no data hint</note>
|
||||
</trans-unit><trans-unit id="driftOnTarget" datatype="html">
|
||||
<source>On target</source>
|
||||
<target state="final">On target</target>
|
||||
<note priority="1" from="description">Altitude on target status</note>
|
||||
</trans-unit><trans-unit id="driftDirectionBelow" datatype="html">
|
||||
<source>below</source>
|
||||
<target state="final">below</target>
|
||||
<note priority="1" from="description">Altitude below target</note>
|
||||
</trans-unit><trans-unit id="driftDirectionAbove" datatype="html">
|
||||
<source>above</source>
|
||||
<target state="final">above</target>
|
||||
<note priority="1" from="description">Altitude above target</note>
|
||||
</trans-unit><trans-unit id="altSourceSprayHeight" datatype="html">
|
||||
<source>Spray Height (Flight Master)</source>
|
||||
<target state="final">Spray Height (Flight Master)</target>
|
||||
<note priority="1" from="description">Altitude source spray height</note>
|
||||
</trans-unit><trans-unit id="altSourceRadar" datatype="html">
|
||||
<source>Radar Altimeter (AGL)</source>
|
||||
<target state="final">Radar Altimeter (AGL)</target>
|
||||
<note priority="1" from="description">Altitude source radar</note>
|
||||
</trans-unit><trans-unit id="altSourceGps" datatype="html">
|
||||
<source>GPS Altitude (MSL)</source>
|
||||
<target state="final">GPS Altitude (MSL)</target>
|
||||
<note priority="1" from="description">Altitude source GPS</note>
|
||||
</trans-unit><trans-unit id="altSourceUnknown" datatype="html">
|
||||
<source>Unknown</source>
|
||||
<target state="translated">Unknown</target>
|
||||
<note priority="1" from="description">Altitude source unknown</note>
|
||||
</trans-unit><trans-unit id="pilotHectaresChartTitle" datatype="html">
|
||||
<source>Hectares Sprayed Per Day</source>
|
||||
<target state="final">Hectares Sprayed Per Day</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart title</note>
|
||||
</trans-unit><trans-unit id="pilotHoursChartTitle" datatype="html">
|
||||
<source>Hours Flown (Week History)</source>
|
||||
<target state="final">Hours Flown (Week History)</target>
|
||||
<note priority="1" from="description">Hours flown chart title</note>
|
||||
</trans-unit><trans-unit id="pilotOperationsTodayTitle" datatype="html">
|
||||
<source>Operations Today</source>
|
||||
<target state="final">Operations Today</target>
|
||||
<note priority="1" from="description">Operations today card title</note>
|
||||
</trans-unit><trans-unit id="pilotDistanceLabel" datatype="html">
|
||||
<source>Distance</source>
|
||||
<target state="translated">Distance</target>
|
||||
<note priority="1" from="description">Distance label</note>
|
||||
</trans-unit><trans-unit id="pilotSprayVolumeLabel" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Spray Volume</target>
|
||||
<note priority="1" from="description">Spray volume label</note>
|
||||
</trans-unit><trans-unit id="pilotDailySummaryTitle" datatype="html">
|
||||
<source>Daily Summary — Today vs Yesterday</source>
|
||||
<target state="final">Daily Summary — Today vs Yesterday</target>
|
||||
<note priority="1" from="description">Daily summary title</note>
|
||||
</trans-unit><trans-unit id="pilotXtErrorTitle" datatype="html">
|
||||
<source>Average XT Error</source>
|
||||
<target state="final">Average XT Error</target>
|
||||
<note priority="1" from="description">XT error indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendIdeal" datatype="html">
|
||||
<source>< <x id="INTERPOLATION"/> m ideal</source>
|
||||
<target state="final">< <x id="INTERPOLATION"/> m ideal</target>
|
||||
<note priority="1" from="description">XT error legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendCaution" datatype="html">
|
||||
<source><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</source>
|
||||
<target state="final"><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</target>
|
||||
<note priority="1" from="description">XT error legend caution</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendHigh" datatype="html">
|
||||
<source>> <x id="INTERPOLATION"/> m high</source>
|
||||
<target state="final">> <x id="INTERPOLATION"/> m high</target>
|
||||
<note priority="1" from="description">XT error legend high</note>
|
||||
</trans-unit><trans-unit id="pilotXtNoData" datatype="html">
|
||||
<source>No spray data available</source>
|
||||
<target state="final">No spray data available</target>
|
||||
<note priority="1" from="description">XT error no data message</note>
|
||||
</trans-unit><trans-unit id="yearly" datatype="html">
|
||||
<source>Yearly</source>
|
||||
<target state="final">Yearly</target>
|
||||
@ -5329,6 +5528,10 @@
|
||||
</trans-unit><trans-unit id="reqField" datatype="html">
|
||||
<source>Required field</source>
|
||||
<target state="final">Required field</target>
|
||||
</trans-unit><trans-unit id="dateRangeLabel" datatype="html">
|
||||
<source>Date Range</source>
|
||||
<target state="final">Date Range</target>
|
||||
<note priority="1" from="description">Date range label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="endSimpleShape" datatype="html">
|
||||
<source>Release dragging to finish drawing.</source>
|
||||
|
||||
@ -5,6 +5,38 @@
|
||||
<trans-unit id="pleaseWait" datatype="html">
|
||||
<source>Please wait</source>
|
||||
<target state="translated">Por favor espere</target>
|
||||
</trans-unit><trans-unit id="kpiAssignedJobs" datatype="html">
|
||||
<source>Assigned Jobs</source>
|
||||
<target state="translated">Trabajos Asignados</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiAssignedHectares" datatype="html">
|
||||
<source>Assigned Hectares</source>
|
||||
<target state="translated">Hectáreas Asignadas</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiHectaresToday" datatype="html">
|
||||
<source>Hectares Sprayed Today</source>
|
||||
<target state="translated">Hectáreas Aplicadas Hoy</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiFlightHours" datatype="html">
|
||||
<source>Flight Hours Today</source>
|
||||
<target state="translated">Horas de Vuelo Hoy</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="summaryHectares" datatype="html">
|
||||
<source>Hectares</source>
|
||||
<target state="translated">Hectáreas</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryFlightHours" datatype="html">
|
||||
<source>Flight Hours</source>
|
||||
<target state="translated">Horas de Vuelo</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryAvgSpeed" datatype="html">
|
||||
<source>Avg Speed</source>
|
||||
<target state="translated">Vel. Promedio</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summarySprayVolume" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Volumen Aplicado</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="totalExTax" datatype="html">
|
||||
<source>Total Excluding Tax</source>
|
||||
@ -41,6 +73,34 @@
|
||||
<x id="CLOSE_PARAGRAPH" ctype="x-p" equiv-text="</p>"/>
|
||||
</target>
|
||||
<note priority="1" from="description">Dislaimer in Dashboard screen</note>
|
||||
</trans-unit><trans-unit id="hoursChartLabel" datatype="html">
|
||||
<source>Hours Flown</source>
|
||||
<target state="translated">Horas de Vuelo</target>
|
||||
<note priority="1" from="description">Hours flown chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHrs" datatype="html">
|
||||
<source>hrs</source>
|
||||
<target state="translated">hrs</target>
|
||||
<note priority="1" from="description">Hours unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="hectaresChartLabel" datatype="html">
|
||||
<source>Hectares Sprayed</source>
|
||||
<target state="translated">Hectáreas Aplicadas</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHa" datatype="html">
|
||||
<source>ha</source>
|
||||
<target state="translated">ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="bandGood" datatype="html">
|
||||
<source>Good</source>
|
||||
<target state="translated">Bueno</target>
|
||||
<note priority="1" from="description">Band label good</note>
|
||||
</trans-unit><trans-unit id="bandMonitor" datatype="html">
|
||||
<source>Monitor</source>
|
||||
<target state="translated">Monitorear</target>
|
||||
<note priority="1" from="description">Band label monitor</note>
|
||||
</trans-unit><trans-unit id="bandHigh" datatype="html">
|
||||
<source>High</source>
|
||||
<target state="translated">Alto</target>
|
||||
<note priority="1" from="description">Band label high</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="promoFree" datatype="html">
|
||||
<source>FREE</source>
|
||||
@ -689,6 +749,162 @@
|
||||
<trans-unit id="contact" datatype="html">
|
||||
<source>Contact</source>
|
||||
<target state="translated">Contacto</target>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsTitle" datatype="html">
|
||||
<source>Active Jobs</source>
|
||||
<target state="translated">Trabajos Activos</target>
|
||||
<note priority="1" from="description">Active jobs panel heading</note>
|
||||
</trans-unit><trans-unit id="pilotViewAllJobs" datatype="html">
|
||||
<source>View All</source>
|
||||
<target state="translated">Ver Todos</target>
|
||||
<note priority="1" from="description">View all jobs link</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsError" datatype="html">
|
||||
<source>Failed to load jobs. Please try again.</source>
|
||||
<target state="translated">Error al cargar trabajos. Inténtelo de nuevo.</target>
|
||||
<note priority="1" from="description">Active jobs error message</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsEmpty" datatype="html">
|
||||
<source>No jobs assigned</source>
|
||||
<target state="translated">Sin trabajos asignados</target>
|
||||
<note priority="1" from="description">Active jobs empty state</note>
|
||||
</trans-unit><trans-unit id="pilotJobClientPrefix" datatype="html">
|
||||
<source>Client: <x id="INTERPOLATION"/></source>
|
||||
<target state="translated">Cliente: <x id="INTERPOLATION"/></target>
|
||||
<note priority="1" from="description">Job client prefix</note>
|
||||
</trans-unit><trans-unit id="pilotLApplied" datatype="html">
|
||||
<source>L applied</source>
|
||||
<target state="translated">L aplicados</target>
|
||||
<note priority="1" from="description">Litres applied label</note>
|
||||
</trans-unit><trans-unit id="pilotJobNotStarted" datatype="html">
|
||||
<source>Not started</source>
|
||||
<target state="translated">No iniciado</target>
|
||||
<note priority="1" from="description">Not started job label</note>
|
||||
</trans-unit><trans-unit id="pilotJobHaUnit" datatype="html">
|
||||
<source>ha</source>
|
||||
<target state="translated">ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="jobStatusNew" datatype="html">
|
||||
<source>NEW</source>
|
||||
<target state="translated">NUEVO</target>
|
||||
<note priority="1" from="description">Job status new</note>
|
||||
</trans-unit><trans-unit id="jobStatusInProgress" datatype="html">
|
||||
<source>IN PROGRESS</source>
|
||||
<target state="translated">EN PROGRESO</target>
|
||||
<note priority="1" from="description">Job status in progress</note>
|
||||
</trans-unit><trans-unit id="jobStatusCompleted" datatype="html">
|
||||
<source>COMPLETED</source>
|
||||
<target state="translated">COMPLETADO</target>
|
||||
<note priority="1" from="description">Job status completed</note>
|
||||
</trans-unit><trans-unit id="pilotAltTitle" datatype="html">
|
||||
<source>Average Altitude Spraying</source>
|
||||
<target state="translated">Altitud Promedio de Aplicación</target>
|
||||
<note priority="1" from="description">Altitude indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendTarget" datatype="html">
|
||||
<target state="translated">Objetivo ~<x id="INTERPOLATION"/> ft</target>
|
||||
<target state="translated">Target ~<x id="INTERPOLATION"/> ft</target>
|
||||
<note priority="1" from="description">Altitude legend target</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendIdeal" datatype="html">
|
||||
<source>±<x id="INTERPOLATION"/> ft ideal</source>
|
||||
<target state="translated">±<x id="INTERPOLATION"/> ft ideal</target>
|
||||
<note priority="1" from="description">Altitude legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendHighRisk" datatype="html">
|
||||
<target state="translated">> ±<x id="INTERPOLATION"/> ft riesgo alto</target>
|
||||
<target state="translated">> ±<x id="INTERPOLATION"/> ft high risk</target>
|
||||
<note priority="1" from="description">Altitude legend high risk</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoData" datatype="html">
|
||||
<source>Altitude data not available</source>
|
||||
<target state="translated">Datos de altitud no disponibles</target>
|
||||
<note priority="1" from="description">Altitude no data message</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoDataHint" datatype="html">
|
||||
<source>(requires Flight Master or radar)</source>
|
||||
<target state="translated">(requiere Flight Master o radar)</target>
|
||||
<note priority="1" from="description">Altitude no data hint</note>
|
||||
</trans-unit><trans-unit id="driftOnTarget" datatype="html">
|
||||
<source>On target</source>
|
||||
<target state="translated">En el objetivo</target>
|
||||
<note priority="1" from="description">Altitude on target status</note>
|
||||
</trans-unit><trans-unit id="driftDirectionBelow" datatype="html">
|
||||
<source>below</source>
|
||||
<target state="translated">por debajo</target>
|
||||
<note priority="1" from="description">Altitude below target</note>
|
||||
</trans-unit><trans-unit id="driftDirectionAbove" datatype="html">
|
||||
<source>above</source>
|
||||
<target state="translated">por encima</target>
|
||||
<note priority="1" from="description">Altitude above target</note>
|
||||
</trans-unit><trans-unit id="altSourceSprayHeight" datatype="html">
|
||||
<source>Spray Height (Flight Master)</source>
|
||||
<target state="translated">Altura de Aplicación (Flight Master)</target>
|
||||
<note priority="1" from="description">Altitude source spray height</note>
|
||||
</trans-unit><trans-unit id="altSourceRadar" datatype="html">
|
||||
<source>Radar Altimeter (AGL)</source>
|
||||
<target state="translated">Altímetro de Radar (AGL)</target>
|
||||
<note priority="1" from="description">Altitude source radar</note>
|
||||
</trans-unit><trans-unit id="altSourceGps" datatype="html">
|
||||
<source>GPS Altitude (MSL)</source>
|
||||
<target state="translated">Altitud GPS (MSL)</target>
|
||||
<note priority="1" from="description">Altitude source GPS</note>
|
||||
</trans-unit><trans-unit id="altSourceUnknown" datatype="html">
|
||||
<source>Unknown</source>
|
||||
<target state="translated">Desconocido</target>
|
||||
<note priority="1" from="description">Altitude source unknown</note>
|
||||
</trans-unit><trans-unit id="pilotHectaresChartTitle" datatype="html">
|
||||
<source>Hectares Sprayed Per Day</source>
|
||||
<target state="translated">Hectáreas Aplicadas por Día</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart title</note>
|
||||
</trans-unit><trans-unit id="pilotHoursChartTitle" datatype="html">
|
||||
<source>Hours Flown (Week History)</source>
|
||||
<target state="translated">Horas de Vuelo (Historial Semanal)</target>
|
||||
<note priority="1" from="description">Hours flown chart title</note>
|
||||
</trans-unit><trans-unit id="errorLoadingHoursChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target state="translated">Error al cargar los datos del gráfico</target>
|
||||
<note priority="1" from="description">Hours chart error</note>
|
||||
</trans-unit><trans-unit id="errorLoadingHectaresChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target state="translated">Error al cargar los datos del gráfico</target>
|
||||
<note priority="1" from="description">Hectares chart error</note>
|
||||
</trans-unit><trans-unit id="pilotHoursChartNoData" datatype="html">
|
||||
<source>No flight activity for this period</source>
|
||||
<target state="translated">Sin actividad de vuelo en este período</target>
|
||||
<note priority="1" from="description">Hours chart no data</note>
|
||||
</trans-unit><trans-unit id="pilotHectaresChartNoData" datatype="html">
|
||||
<source>No spray activity for this period</source>
|
||||
<target state="translated">Sin actividad de aplicación en este período</target>
|
||||
<note priority="1" from="description">Hectares chart no data</note>
|
||||
</trans-unit><trans-unit id="pilotOperationsTodayTitle" datatype="html">
|
||||
<source>Operations Today</source>
|
||||
<target state="translated">Operaciones de Hoy</target>
|
||||
<note priority="1" from="description">Operations today card title</note>
|
||||
</trans-unit><trans-unit id="pilotDistanceLabel" datatype="html">
|
||||
<source>Distance</source>
|
||||
<target state="translated">Distancia</target>
|
||||
<note priority="1" from="description">Distance label</note>
|
||||
</trans-unit><trans-unit id="pilotSprayVolumeLabel" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Volumen Aplicado</target>
|
||||
<note priority="1" from="description">Spray volume label</note>
|
||||
</trans-unit><trans-unit id="pilotDailySummaryTitle" datatype="html">
|
||||
<source>Daily Summary — Today vs Yesterday</source>
|
||||
<target state="translated">Resumen Diario — Hoy vs Ayer</target>
|
||||
<note priority="1" from="description">Daily summary title</note>
|
||||
</trans-unit><trans-unit id="pilotXtErrorTitle" datatype="html">
|
||||
<source>Average XT Error</source>
|
||||
<target state="translated">Error XT Promedio</target>
|
||||
<note priority="1" from="description">XT error indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendIdeal" datatype="html">
|
||||
<source>< <x id="INTERPOLATION"/> m ideal</source>
|
||||
<target state="translated">< <x id="INTERPOLATION"/> m ideal</target>
|
||||
<note priority="1" from="description">XT error legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendCaution" datatype="html">
|
||||
<target state="translated"><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m precaución</target>
|
||||
<target state="translated"><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</target>
|
||||
<note priority="1" from="description">XT error legend caution</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendHigh" datatype="html">
|
||||
<target state="translated">> <x id="INTERPOLATION"/> m alto</target>
|
||||
<target state="translated">> <x id="INTERPOLATION"/> m high</target>
|
||||
<note priority="1" from="description">XT error legend high</note>
|
||||
</trans-unit><trans-unit id="pilotXtNoData" datatype="html">
|
||||
<source>No spray data available</source>
|
||||
<target state="translated">Sin datos de aplicación disponibles</target>
|
||||
<note priority="1" from="description">XT error no data message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="yearly" datatype="html">
|
||||
<source>Yearly</source>
|
||||
@ -1754,6 +1970,30 @@
|
||||
<source>Failed to load promos. Please refresh the page.</source>
|
||||
<target state="needs-l10n">No se pudieron cargar las promociones. Actualice la página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingKpi" datatype="html">
|
||||
<source>Failed to load KPI data. Please refresh the page.</source>
|
||||
<target state="needs-l10n">No se pudieron cargar los datos de KPI. Actualice la página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingSummary" datatype="html">
|
||||
<source>Failed to load daily summary. Please refresh the page.</source>
|
||||
<target state="needs-l10n">No se pudo cargar el resumen diario. Actualice la página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummarySuccess" datatype="html">
|
||||
<source>Success</source>
|
||||
<target state="needs-l10n">Éxito</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryInfo" datatype="html">
|
||||
<source>Info</source>
|
||||
<target state="needs-l10n">Información</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryWarn" datatype="html">
|
||||
<source>Warn</source>
|
||||
<target state="needs-l10n">Advertencia</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryError" datatype="html">
|
||||
<source>Error</source>
|
||||
<target state="needs-l10n">Error</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingCoupons" datatype="html">
|
||||
<source>Failed to load coupons. Some features may be unavailable.</source>
|
||||
<target state="needs-l10n">No se pudieron cargar los cupones. Es posible que algunas funciones no estén disponibles.</target>
|
||||
@ -4533,6 +4773,9 @@
|
||||
<trans-unit id="sprayed" datatype="html">
|
||||
<source>Sprayed</source>
|
||||
<target state="translated">Pulverizada</target>
|
||||
</trans-unit><trans-unit id="completed" datatype="html">
|
||||
<source>Completed</source>
|
||||
<target state="translated">Aplicado</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="archived" datatype="html">
|
||||
<source>Archived</source>
|
||||
@ -6119,6 +6362,10 @@
|
||||
<trans-unit id="reqField" datatype="html">
|
||||
<source>Required field</source>
|
||||
<target state="translated">Campo obligatorio</target>
|
||||
</trans-unit><trans-unit id="dateRangeLabel" datatype="html">
|
||||
<source>Date Range</source>
|
||||
<target state="translated">Rango de Fechas</target>
|
||||
<note priority="1" from="description">Date range label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="endSimpleShape" datatype="html">
|
||||
<source>Release dragging to finish drawing.</source>
|
||||
|
||||
@ -5,6 +5,38 @@
|
||||
<trans-unit id="pleaseWait" datatype="html">
|
||||
<source>Please wait</source>
|
||||
<target state="translated">Por favor, aguarde</target>
|
||||
</trans-unit><trans-unit id="kpiAssignedJobs" datatype="html">
|
||||
<source>Assigned Jobs</source>
|
||||
<target state="translated">Tarefas Atribuídas</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiAssignedHectares" datatype="html">
|
||||
<source>Assigned Hectares</source>
|
||||
<target state="translated">Hectares Atribuídos</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiHectaresToday" datatype="html">
|
||||
<source>Hectares Sprayed Today</source>
|
||||
<target state="translated">Hectares Aplicados Hoje</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="kpiFlightHours" datatype="html">
|
||||
<source>Flight Hours Today</source>
|
||||
<target state="translated">Horas de Voo Hoje</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit><trans-unit id="summaryHectares" datatype="html">
|
||||
<source>Hectares</source>
|
||||
<target state="translated">Hectares</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryFlightHours" datatype="html">
|
||||
<source>Flight Hours</source>
|
||||
<target state="translated">Horas de Voo</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summaryAvgSpeed" datatype="html">
|
||||
<source>Avg Speed</source>
|
||||
<target state="translated">Vel. Média</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit><trans-unit id="summarySprayVolume" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Volume Aplicado</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="totalExTax" datatype="html">
|
||||
<source>Total Excluding Tax</source>
|
||||
@ -41,6 +73,34 @@
|
||||
<x id="CLOSE_PARAGRAPH" ctype="x-p" equiv-text="</p>"/>
|
||||
</target>
|
||||
<note priority="1" from="description">Dislaimer in Dashboard screen</note>
|
||||
</trans-unit><trans-unit id="hoursChartLabel" datatype="html">
|
||||
<source>Hours Flown</source>
|
||||
<target state="translated">Horas de Voo</target>
|
||||
<note priority="1" from="description">Hours flown chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHrs" datatype="html">
|
||||
<source>hrs</source>
|
||||
<target state="translated">hrs</target>
|
||||
<note priority="1" from="description">Hours unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="hectaresChartLabel" datatype="html">
|
||||
<source>Hectares Sprayed</source>
|
||||
<target state="translated">Hectares Aplicados</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart dataset label</note>
|
||||
</trans-unit><trans-unit id="unitHa" datatype="html">
|
||||
<source>ha</source>
|
||||
<target state="translated">ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="bandGood" datatype="html">
|
||||
<source>Good</source>
|
||||
<target state="translated">Bom</target>
|
||||
<note priority="1" from="description">Band label good</note>
|
||||
</trans-unit><trans-unit id="bandMonitor" datatype="html">
|
||||
<source>Monitor</source>
|
||||
<target state="translated">Monitorar</target>
|
||||
<note priority="1" from="description">Band label monitor</note>
|
||||
</trans-unit><trans-unit id="bandHigh" datatype="html">
|
||||
<source>High</source>
|
||||
<target state="translated">Alto</target>
|
||||
<note priority="1" from="description">Band label high</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="promoFree" datatype="html">
|
||||
<source>FREE</source>
|
||||
@ -684,6 +744,162 @@
|
||||
<trans-unit id="contact" datatype="html">
|
||||
<source>Contact</source>
|
||||
<target state="translated">contato</target>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsTitle" datatype="html">
|
||||
<source>Active Jobs</source>
|
||||
<target state="translated">Tarefas Ativas</target>
|
||||
<note priority="1" from="description">Active jobs panel heading</note>
|
||||
</trans-unit><trans-unit id="pilotViewAllJobs" datatype="html">
|
||||
<source>View All</source>
|
||||
<target state="translated">Ver Todas</target>
|
||||
<note priority="1" from="description">View all jobs link</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsError" datatype="html">
|
||||
<source>Failed to load jobs. Please try again.</source>
|
||||
<target state="translated">Falha ao carregar tarefas. Tente novamente.</target>
|
||||
<note priority="1" from="description">Active jobs error message</note>
|
||||
</trans-unit><trans-unit id="pilotActiveJobsEmpty" datatype="html">
|
||||
<source>No jobs assigned</source>
|
||||
<target state="translated">Nenhuma tarefa atribuída</target>
|
||||
<note priority="1" from="description">Active jobs empty state</note>
|
||||
</trans-unit><trans-unit id="pilotJobClientPrefix" datatype="html">
|
||||
<source>Client: <x id="INTERPOLATION"/></source>
|
||||
<target state="translated">Cliente: <x id="INTERPOLATION"/></target>
|
||||
<note priority="1" from="description">Job client prefix</note>
|
||||
</trans-unit><trans-unit id="pilotLApplied" datatype="html">
|
||||
<source>L applied</source>
|
||||
<target state="translated">L aplicados</target>
|
||||
<note priority="1" from="description">Litres applied label</note>
|
||||
</trans-unit><trans-unit id="pilotJobNotStarted" datatype="html">
|
||||
<source>Not started</source>
|
||||
<target state="translated">Não iniciado</target>
|
||||
<note priority="1" from="description">Not started job label</note>
|
||||
</trans-unit><trans-unit id="pilotJobHaUnit" datatype="html">
|
||||
<source>ha</source>
|
||||
<target state="translated">ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit><trans-unit id="jobStatusNew" datatype="html">
|
||||
<source>NEW</source>
|
||||
<target state="translated">NOVA</target>
|
||||
<note priority="1" from="description">Job status new</note>
|
||||
</trans-unit><trans-unit id="jobStatusInProgress" datatype="html">
|
||||
<source>IN PROGRESS</source>
|
||||
<target state="translated">EM ANDAMENTO</target>
|
||||
<note priority="1" from="description">Job status in progress</note>
|
||||
</trans-unit><trans-unit id="jobStatusCompleted" datatype="html">
|
||||
<source>COMPLETED</source>
|
||||
<target state="translated">CONCLUÍDA</target>
|
||||
<note priority="1" from="description">Job status completed</note>
|
||||
</trans-unit><trans-unit id="pilotAltTitle" datatype="html">
|
||||
<source>Average Altitude Spraying</source>
|
||||
<target state="translated">Altitude Média de Aplicação</target>
|
||||
<note priority="1" from="description">Altitude indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendTarget" datatype="html">
|
||||
<source>Target ~<x id="INTERPOLATION"/> ft</source>
|
||||
<target state="translated">Alvo ~<x id="INTERPOLATION"/> ft</target>
|
||||
<note priority="1" from="description">Altitude legend target</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendIdeal" datatype="html">
|
||||
<source>±<x id="INTERPOLATION"/> ft ideal</source>
|
||||
<target state="translated">±<x id="INTERPOLATION"/> ft ideal</target>
|
||||
<note priority="1" from="description">Altitude legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotAltLegendHighRisk" datatype="html">
|
||||
<source>> ±<x id="INTERPOLATION"/> ft high risk</source>
|
||||
<target state="translated">> ±<x id="INTERPOLATION"/> ft risco alto</target>
|
||||
<note priority="1" from="description">Altitude legend high risk</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoData" datatype="html">
|
||||
<source>Altitude data not available</source>
|
||||
<target state="translated">Dados de altitude não disponíveis</target>
|
||||
<note priority="1" from="description">Altitude no data message</note>
|
||||
</trans-unit><trans-unit id="pilotAltNoDataHint" datatype="html">
|
||||
<source>(requires Flight Master or radar)</source>
|
||||
<target state="translated">(requer Flight Master ou radar)</target>
|
||||
<note priority="1" from="description">Altitude no data hint</note>
|
||||
</trans-unit><trans-unit id="driftOnTarget" datatype="html">
|
||||
<source>On target</source>
|
||||
<target state="translated">No alvo</target>
|
||||
<note priority="1" from="description">Altitude on target status</note>
|
||||
</trans-unit><trans-unit id="driftDirectionBelow" datatype="html">
|
||||
<source>below</source>
|
||||
<target state="translated">abaixo</target>
|
||||
<note priority="1" from="description">Altitude below target</note>
|
||||
</trans-unit><trans-unit id="driftDirectionAbove" datatype="html">
|
||||
<source>above</source>
|
||||
<target state="translated">acima</target>
|
||||
<note priority="1" from="description">Altitude above target</note>
|
||||
</trans-unit><trans-unit id="altSourceSprayHeight" datatype="html">
|
||||
<source>Spray Height (Flight Master)</source>
|
||||
<target state="translated">Altura de Aplicação (Flight Master)</target>
|
||||
<note priority="1" from="description">Altitude source spray height</note>
|
||||
</trans-unit><trans-unit id="altSourceRadar" datatype="html">
|
||||
<source>Radar Altimeter (AGL)</source>
|
||||
<target state="translated">Altímetro de Radar (AGL)</target>
|
||||
<note priority="1" from="description">Altitude source radar</note>
|
||||
</trans-unit><trans-unit id="altSourceGps" datatype="html">
|
||||
<source>GPS Altitude (MSL)</source>
|
||||
<target state="translated">Altitude GPS (MSL)</target>
|
||||
<note priority="1" from="description">Altitude source GPS</note>
|
||||
</trans-unit><trans-unit id="altSourceUnknown" datatype="html">
|
||||
<source>Unknown</source>
|
||||
<target state="translated">Desconhecido</target>
|
||||
<note priority="1" from="description">Altitude source unknown</note>
|
||||
</trans-unit><trans-unit id="pilotHectaresChartTitle" datatype="html">
|
||||
<source>Hectares Sprayed Per Day</source>
|
||||
<target state="translated">Hectares Aplicados por Dia</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart title</note>
|
||||
</trans-unit><trans-unit id="pilotHoursChartTitle" datatype="html">
|
||||
<source>Hours Flown (Week History)</source>
|
||||
<target state="translated">Horas de Voo (Histórico Semanal)</target>
|
||||
<note priority="1" from="description">Hours flown chart title</note>
|
||||
</trans-unit><trans-unit id="errorLoadingHoursChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target state="translated">Falha ao carregar dados do gráfico</target>
|
||||
<note priority="1" from="description">Hours chart error</note>
|
||||
</trans-unit><trans-unit id="errorLoadingHectaresChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target state="translated">Falha ao carregar dados do gráfico</target>
|
||||
<note priority="1" from="description">Hectares chart error</note>
|
||||
</trans-unit><trans-unit id="pilotHoursChartNoData" datatype="html">
|
||||
<source>No flight activity for this period</source>
|
||||
<target state="translated">Sem atividade de voo neste período</target>
|
||||
<note priority="1" from="description">Hours chart no data</note>
|
||||
</trans-unit><trans-unit id="pilotHectaresChartNoData" datatype="html">
|
||||
<source>No spray activity for this period</source>
|
||||
<target state="translated">Sem atividade de aplicação neste período</target>
|
||||
<note priority="1" from="description">Hectares chart no data</note>
|
||||
</trans-unit><trans-unit id="pilotOperationsTodayTitle" datatype="html">
|
||||
<source>Operations Today</source>
|
||||
<target state="translated">Operações de Hoje</target>
|
||||
<note priority="1" from="description">Operations today card title</note>
|
||||
</trans-unit><trans-unit id="pilotDistanceLabel" datatype="html">
|
||||
<source>Distance</source>
|
||||
<target state="translated">Distância</target>
|
||||
<note priority="1" from="description">Distance label</note>
|
||||
</trans-unit><trans-unit id="pilotSprayVolumeLabel" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target state="translated">Volume Aplicado</target>
|
||||
<note priority="1" from="description">Spray volume label</note>
|
||||
</trans-unit><trans-unit id="pilotDailySummaryTitle" datatype="html">
|
||||
<source>Daily Summary — Today vs Yesterday</source>
|
||||
<target state="translated">Resumo Diário — Hoje vs Ontem</target>
|
||||
<note priority="1" from="description">Daily summary title</note>
|
||||
</trans-unit><trans-unit id="pilotXtErrorTitle" datatype="html">
|
||||
<source>Average XT Error</source>
|
||||
<target state="translated">Erro XT Médio</target>
|
||||
<note priority="1" from="description">XT error indicator card title</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendIdeal" datatype="html">
|
||||
<source>< <x id="INTERPOLATION"/> m ideal</source>
|
||||
<target state="translated">< <x id="INTERPOLATION"/> m ideal</target>
|
||||
<note priority="1" from="description">XT error legend ideal</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendCaution" datatype="html">
|
||||
<source><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</source>
|
||||
<target state="translated"><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m atenção</target>
|
||||
<note priority="1" from="description">XT error legend caution</note>
|
||||
</trans-unit><trans-unit id="pilotXtLegendHigh" datatype="html">
|
||||
<source>> <x id="INTERPOLATION"/> m high</source>
|
||||
<target state="translated">> <x id="INTERPOLATION"/> m alto</target>
|
||||
<note priority="1" from="description">XT error legend high</note>
|
||||
</trans-unit><trans-unit id="pilotXtNoData" datatype="html">
|
||||
<source>No spray data available</source>
|
||||
<target state="translated">Sem dados de aplicação disponíveis</target>
|
||||
<note priority="1" from="description">XT error no data message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="yearly" datatype="html">
|
||||
<source>Yearly</source>
|
||||
@ -1667,6 +1883,30 @@
|
||||
<source>Failed to load promos. Please refresh the page.</source>
|
||||
<target state="needs-l10n">Não foi possível carregar as promoções. Atualize a página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingKpi" datatype="html">
|
||||
<source>Failed to load KPI data. Please refresh the page.</source>
|
||||
<target state="needs-l10n">Não foi possível carregar os dados de KPI. Atualize a página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingSummary" datatype="html">
|
||||
<source>Failed to load daily summary. Please refresh the page.</source>
|
||||
<target state="needs-l10n">Não foi possível carregar o resumo diário. Atualize a página.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummarySuccess" datatype="html">
|
||||
<source>Success</source>
|
||||
<target state="needs-l10n">Sucesso</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryInfo" datatype="html">
|
||||
<source>Info</source>
|
||||
<target state="needs-l10n">Informação</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryWarn" datatype="html">
|
||||
<source>Warn</source>
|
||||
<target state="needs-l10n">Aviso</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryError" datatype="html">
|
||||
<source>Error</source>
|
||||
<target state="needs-l10n">Erro</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingCoupons" datatype="html">
|
||||
<source>Failed to load coupons. Some features may be unavailable.</source>
|
||||
<target state="needs-l10n">Não foi possível carregar os cupons. Algumas funcionalidades podem estar indisponíveis.</target>
|
||||
@ -4533,6 +4773,9 @@
|
||||
<trans-unit id="sprayed" datatype="html">
|
||||
<source>Sprayed</source>
|
||||
<target state="translated">Pulverizada</target>
|
||||
</trans-unit><trans-unit id="completed" datatype="html">
|
||||
<source>Completed</source>
|
||||
<target state="translated">Aplicado</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="archived" datatype="html">
|
||||
<source>Archived</source>
|
||||
@ -6119,6 +6362,10 @@
|
||||
<trans-unit id="reqField" datatype="html">
|
||||
<source>Required field</source>
|
||||
<target state="translated">Campo obrigatório</target>
|
||||
</trans-unit><trans-unit id="dateRangeLabel" datatype="html">
|
||||
<source>Date Range</source>
|
||||
<target state="translated">Intervalo de Datas</target>
|
||||
<note priority="1" from="description">Date range label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="endSimpleShape" datatype="html">
|
||||
<source>Release dragging to finish drawing.</source>
|
||||
|
||||
@ -1891,6 +1891,201 @@
|
||||
<source>Contact</source>
|
||||
<target>Contact</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotActiveJobsTitle" datatype="html">
|
||||
<source>Active Jobs</source>
|
||||
<target>Active Jobs</target>
|
||||
<note priority="1" from="description">Active jobs panel heading</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotViewAllJobs" datatype="html">
|
||||
<source>View All</source>
|
||||
<target>View All</target>
|
||||
<note priority="1" from="description">View all jobs link</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotActiveJobsError" datatype="html">
|
||||
<source>Failed to load jobs. Please try again.</source>
|
||||
<target>Failed to load jobs. Please try again.</target>
|
||||
<note priority="1" from="description">Active jobs error message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotActiveJobsEmpty" datatype="html">
|
||||
<source>No jobs assigned</source>
|
||||
<target>No jobs assigned</target>
|
||||
<note priority="1" from="description">Active jobs empty state</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotJobClientPrefix" datatype="html">
|
||||
<source>Client: <x id="INTERPOLATION"/></source>
|
||||
<target>Client: <x id="INTERPOLATION"/></target>
|
||||
<note priority="1" from="description">Job client prefix</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotLApplied" datatype="html">
|
||||
<source>L applied</source>
|
||||
<target>L applied</target>
|
||||
<note priority="1" from="description">Litres applied label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotJobNotStarted" datatype="html">
|
||||
<source>Not started</source>
|
||||
<target>Not started</target>
|
||||
<note priority="1" from="description">Not started job label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotJobHaUnit" datatype="html">
|
||||
<source>ha</source>
|
||||
<target>ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="jobStatusNew" datatype="html">
|
||||
<source>NEW</source>
|
||||
<target>NEW</target>
|
||||
<note priority="1" from="description">Job status new</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="jobStatusInProgress" datatype="html">
|
||||
<source>IN PROGRESS</source>
|
||||
<target>IN PROGRESS</target>
|
||||
<note priority="1" from="description">Job status in progress</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="jobStatusCompleted" datatype="html">
|
||||
<source>COMPLETED</source>
|
||||
<target>COMPLETED</target>
|
||||
<note priority="1" from="description">Job status completed</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltTitle" datatype="html">
|
||||
<source>Average Altitude Spraying</source>
|
||||
<target>Average Altitude Spraying</target>
|
||||
<note priority="1" from="description">Altitude indicator card title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltLegendTarget" datatype="html">
|
||||
<source>Target ~<x id="INTERPOLATION"/> ft</source>
|
||||
<target>Target ~<x id="INTERPOLATION"/> ft</target>
|
||||
<note priority="1" from="description">Altitude legend target</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltLegendIdeal" datatype="html">
|
||||
<source>±<x id="INTERPOLATION"/> ft ideal</source>
|
||||
<target>±<x id="INTERPOLATION"/> ft ideal</target>
|
||||
<note priority="1" from="description">Altitude legend ideal</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltLegendHighRisk" datatype="html">
|
||||
<source>> ±<x id="INTERPOLATION"/> ft high risk</source>
|
||||
<target>> ±<x id="INTERPOLATION"/> ft high risk</target>
|
||||
<note priority="1" from="description">Altitude legend high risk</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltNoData" datatype="html">
|
||||
<source>Altitude data not available</source>
|
||||
<target>Altitude data not available</target>
|
||||
<note priority="1" from="description">Altitude no data message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotAltNoDataHint" datatype="html">
|
||||
<source>(requires Flight Master or radar)</source>
|
||||
<target>(requires Flight Master or radar)</target>
|
||||
<note priority="1" from="description">Altitude no data hint</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="driftOnTarget" datatype="html">
|
||||
<source>On target</source>
|
||||
<target>On target</target>
|
||||
<note priority="1" from="description">Altitude on target status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="driftDirectionBelow" datatype="html">
|
||||
<source>below</source>
|
||||
<target>below</target>
|
||||
<note priority="1" from="description">Altitude below target</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="driftDirectionAbove" datatype="html">
|
||||
<source>above</source>
|
||||
<target>above</target>
|
||||
<note priority="1" from="description">Altitude above target</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="altSourceSprayHeight" datatype="html">
|
||||
<source>Spray Height (Flight Master)</source>
|
||||
<target>Spray Height (Flight Master)</target>
|
||||
<note priority="1" from="description">Altitude source spray height</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="altSourceRadar" datatype="html">
|
||||
<source>Radar Altimeter (AGL)</source>
|
||||
<target>Radar Altimeter (AGL)</target>
|
||||
<note priority="1" from="description">Altitude source radar</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="altSourceGps" datatype="html">
|
||||
<source>GPS Altitude (MSL)</source>
|
||||
<target>GPS Altitude (MSL)</target>
|
||||
<note priority="1" from="description">Altitude source GPS</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="altSourceUnknown" datatype="html">
|
||||
<source>Unknown</source>
|
||||
<target>Unknown</target>
|
||||
<note priority="1" from="description">Altitude source unknown</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotHectaresChartTitle" datatype="html">
|
||||
<source>Hectares Sprayed Per Day</source>
|
||||
<target>Hectares Sprayed Per Day</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotHoursChartTitle" datatype="html">
|
||||
<source>Hours Flown (Week History)</source>
|
||||
<target>Hours Flown (Week History)</target>
|
||||
<note priority="1" from="description">Hours flown chart title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingHoursChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target>Failed to load chart data</target>
|
||||
<note priority="1" from="description">Hours chart error</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingHectaresChart" datatype="html">
|
||||
<source>Failed to load chart data</source>
|
||||
<target>Failed to load chart data</target>
|
||||
<note priority="1" from="description">Hectares chart error</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotHoursChartNoData" datatype="html">
|
||||
<source>No flight activity for this period</source>
|
||||
<target>No flight activity for this period</target>
|
||||
<note priority="1" from="description">Hours chart no data</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotHectaresChartNoData" datatype="html">
|
||||
<source>No spray activity for this period</source>
|
||||
<target>No spray activity for this period</target>
|
||||
<note priority="1" from="description">Hectares chart no data</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotOperationsTodayTitle" datatype="html">
|
||||
<source>Operations Today</source>
|
||||
<target>Operations Today</target>
|
||||
<note priority="1" from="description">Operations today card title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotDistanceLabel" datatype="html">
|
||||
<source>Distance</source>
|
||||
<target>Distance</target>
|
||||
<note priority="1" from="description">Distance label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotSprayVolumeLabel" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target>Spray Volume</target>
|
||||
<note priority="1" from="description">Spray volume label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotDailySummaryTitle" datatype="html">
|
||||
<source>Daily Summary — Today vs Yesterday</source>
|
||||
<target>Daily Summary — Today vs Yesterday</target>
|
||||
<note priority="1" from="description">Daily summary title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotXtErrorTitle" datatype="html">
|
||||
<source>Average XT Error</source>
|
||||
<target>Average XT Error</target>
|
||||
<note priority="1" from="description">XT error indicator card title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotXtLegendIdeal" datatype="html">
|
||||
<source>< <x id="INTERPOLATION"/> m ideal</source>
|
||||
<target>< <x id="INTERPOLATION"/> m ideal</target>
|
||||
<note priority="1" from="description">XT error legend ideal</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotXtLegendCaution" datatype="html">
|
||||
<source><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</source>
|
||||
<target><x id="INTERPOLATION"/>–<x id="INTERPOLATION_1"/> m caution</target>
|
||||
<note priority="1" from="description">XT error legend caution</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotXtLegendHigh" datatype="html">
|
||||
<source>> <x id="INTERPOLATION"/> m high</source>
|
||||
<target>> <x id="INTERPOLATION"/> m high</target>
|
||||
<note priority="1" from="description">XT error legend high</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="pilotXtNoData" datatype="html">
|
||||
<source>No spray data available</source>
|
||||
<target>No spray data available</target>
|
||||
<note priority="1" from="description">XT error no data message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="welcome" datatype="html">
|
||||
<source>Welcome to AgMission</source>
|
||||
<target>Welcome to AgMission</target>
|
||||
@ -1901,6 +2096,41 @@
|
||||
<target>Copyright© 2018. AgMission of AG-NAV Inc. All Rights Reserved.<x id="START_PARAGRAPH"/>IN NO EVENT SHALL AG-NAV BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES, INCLUDING LOSS OF USE, DATA, OR PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION EVEN IF AG-NAV HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is provided “AS IS” WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. AG-NAV assumes no responsibility for errors or omissions in the software or documentation available from the agnav.com website.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>AgMission is in the beta phase. AG-NAV will update AgMission whenever necessary without notice. The user is granted the permission to use the software free of charge until further notice.<x id="CLOSE_PARAGRAPH"/><x id="START_PARAGRAPH"/>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.<x id="CLOSE_PARAGRAPH"/></target>
|
||||
<note priority="1" from="description">Dislaimer in Dashboard screen</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="hoursChartLabel" datatype="html">
|
||||
<source>Hours Flown</source>
|
||||
<target>Hours Flown</target>
|
||||
<note priority="1" from="description">Hours flown chart dataset label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="unitHrs" datatype="html">
|
||||
<source>hrs</source>
|
||||
<target>hrs</target>
|
||||
<note priority="1" from="description">Hours unit abbreviation</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="hectaresChartLabel" datatype="html">
|
||||
<source>Hectares Sprayed</source>
|
||||
<target>Hectares Sprayed</target>
|
||||
<note priority="1" from="description">Hectares sprayed chart dataset label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="unitHa" datatype="html">
|
||||
<source>ha</source>
|
||||
<target>ha</target>
|
||||
<note priority="1" from="description">Hectares unit abbreviation</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bandGood" datatype="html">
|
||||
<source>Good</source>
|
||||
<target>Good</target>
|
||||
<note priority="1" from="description">Band label good</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bandMonitor" datatype="html">
|
||||
<source>Monitor</source>
|
||||
<target>Monitor</target>
|
||||
<note priority="1" from="description">Band label monitor</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bandHigh" datatype="html">
|
||||
<source>High</source>
|
||||
<target>High</target>
|
||||
<note priority="1" from="description">Band label high</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="promoFree" datatype="html">
|
||||
<source>FREE</source>
|
||||
<target>FREE</target>
|
||||
@ -2923,6 +3153,11 @@
|
||||
<source>Required field</source>
|
||||
<target>Required field</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="dateRangeLabel" datatype="html">
|
||||
<source>Date Range</source>
|
||||
<target>Date Range</target>
|
||||
<note priority="1" from="description">Date range label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="diplay" datatype="html">
|
||||
<source>Display</source>
|
||||
<target>Display</target>
|
||||
@ -4503,6 +4738,10 @@
|
||||
<source>Sprayed</source>
|
||||
<target>Sprayed</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="completed" datatype="html">
|
||||
<source>Completed</source>
|
||||
<target>Completed</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="archived" datatype="html">
|
||||
<source>Archived</source>
|
||||
<target>Archived</target>
|
||||
@ -5094,6 +5333,46 @@
|
||||
<source>Please wait</source>
|
||||
<target>Please wait</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="kpiAssignedJobs" datatype="html">
|
||||
<source>Assigned Jobs</source>
|
||||
<target>Assigned Jobs</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="kpiAssignedHectares" datatype="html">
|
||||
<source>Assigned Hectares</source>
|
||||
<target>Assigned Hectares</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="kpiHectaresToday" datatype="html">
|
||||
<source>Hectares Sprayed Today</source>
|
||||
<target>Hectares Sprayed Today</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="kpiFlightHours" datatype="html">
|
||||
<source>Flight Hours Today</source>
|
||||
<target>Flight Hours Today</target>
|
||||
<note priority="1" from="description">KPI label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="summaryHectares" datatype="html">
|
||||
<source>Hectares</source>
|
||||
<target>Hectares</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="summaryFlightHours" datatype="html">
|
||||
<source>Flight Hours</source>
|
||||
<target>Flight Hours</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="summaryAvgSpeed" datatype="html">
|
||||
<source>Avg Speed</source>
|
||||
<target>Avg Speed</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="summarySprayVolume" datatype="html">
|
||||
<source>Spray Volume</source>
|
||||
<target>Spray Volume</target>
|
||||
<note priority="1" from="description">Summary label</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="totalExTax" datatype="html">
|
||||
<source>Total Excluding Tax</source>
|
||||
<target>Total Excluding Tax</target>
|
||||
@ -5824,6 +6103,30 @@
|
||||
<source>Failed to load promos. Please refresh the page.</source>
|
||||
<target>Failed to load promos. Please refresh the page.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingKpi" datatype="html">
|
||||
<source>Failed to load KPI data. Please refresh the page.</source>
|
||||
<target>Failed to load KPI data. Please refresh the page.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingSummary" datatype="html">
|
||||
<source>Failed to load daily summary. Please refresh the page.</source>
|
||||
<target>Failed to load daily summary. Please refresh the page.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummarySuccess" datatype="html">
|
||||
<source>Success</source>
|
||||
<target>Success</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryInfo" datatype="html">
|
||||
<source>Info</source>
|
||||
<target>Info</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryWarn" datatype="html">
|
||||
<source>Warn</source>
|
||||
<target>Warn</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="toastSummaryError" datatype="html">
|
||||
<source>Error</source>
|
||||
<target>Error</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="errorLoadingCoupons" datatype="html">
|
||||
<source>Failed to load coupons. Some features may be unavailable.</source>
|
||||
<target>Failed to load coupons. Some features may be unavailable.</target>
|
||||
|
||||
0
Development/client/src/shared/mock/DELETE_ME.txt
Normal file
0
Development/client/src/shared/mock/DELETE_ME.txt
Normal file
20
Development/client/src/shared/mock/pilot-dashboard-mock.ts
Normal file
20
Development/client/src/shared/mock/pilot-dashboard-mock.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// pilot-dashboard-mock.ts
|
||||
// Mock data for Pilot Dashboard features
|
||||
|
||||
export const MOCK_OPERATIONS_TODAY = {
|
||||
distanceKm: 42.7,
|
||||
sprayVolumeL: 8350
|
||||
};
|
||||
|
||||
export const MOCK_KPI_DATA = [
|
||||
// ...existing KPI mock data...
|
||||
];
|
||||
|
||||
export const MOCK_SUMMARY_DATA = [
|
||||
// ...existing summary mock data...
|
||||
];
|
||||
|
||||
export const MOCK_OPERATIONS_TODAY = {
|
||||
distanceKm: 42.7,
|
||||
sprayVolumeL: 8350
|
||||
};
|
||||
@ -1912,6 +1912,62 @@ body .form-dropdown .ui-dropdown {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Week-picker mode — clicking a week number or any day selects the full Sun–Sat week.
|
||||
// panelStyleClass="week-picker" is applied directly to the overlay panel by PrimeNG 9.
|
||||
// The overlay is appended to body so these rules must live in global styles.
|
||||
|
||||
// Week-number column header
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar thead th.ui-datepicker-weekheader {
|
||||
color: #3a4450;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
// Week-number cells: make them look clickable
|
||||
body .ui-datepicker.week-picker td.ui-datepicker-weeknumber {
|
||||
cursor: pointer;
|
||||
color: #5a6473;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
border-right: 1px solid #e8eaed;
|
||||
background: #f8f9fa;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
body .ui-datepicker.week-picker tr:hover td.ui-datepicker-weeknumber {
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
// Entire row hover: highlight all 7 day cells as a pill strip
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:not(.ui-datepicker-weeknumber) > a,
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:not(.ui-datepicker-weeknumber) > span {
|
||||
background: #e8f5e9;
|
||||
border-color: transparent;
|
||||
border-radius: 0;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
// Round left cap on Sunday (first non-Wk cell)
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:nth-child(2) > a,
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:nth-child(2) > span {
|
||||
border-radius: 50% 0 0 50%;
|
||||
}
|
||||
|
||||
// Round right cap on Saturday (last cell)
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:last-child > a,
|
||||
body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:last-child > span {
|
||||
border-radius: 0 50% 50% 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Form calendar internals — PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button]
|
||||
body span.form-calendar.ui-calendar,
|
||||
body .form-calendar.ui-calendar,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
|
||||
|
@ -1 +0,0 @@
|
||||
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
|
||||
|
50
Development/server/.vscode/launch.json
vendored
50
Development/server/.vscode/launch.json
vendored
@ -72,6 +72,56 @@
|
||||
"envFile": "${workspaceFolder}/environment_test_ScaleGrid.env",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Mocha: test_pilot_dashboard_api",
|
||||
"runtimeVersion": "16.20.2",
|
||||
"runtimeArgs": [
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=2048",
|
||||
"--nouse-idle-notification",
|
||||
"--icu-data-dir=/home/trung/.nvm/versions/node/v16.20.2/lib/node_modules/full-icu",
|
||||
"--trace-warnings"
|
||||
],
|
||||
"program": "${workspaceFolder}/node_modules/.bin/_mocha",
|
||||
"args": [
|
||||
"--exit",
|
||||
"--require", "tests/setup.js",
|
||||
"tests/test_pilot_dashboard_api.js"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"envFile": "${workspaceFolder}/environment.env",
|
||||
"env": {
|
||||
"DEBUG": "agm:*"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Mocha: Current Test File",
|
||||
"runtimeVersion": "16.20.2",
|
||||
"runtimeArgs": [
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=2048",
|
||||
"--nouse-idle-notification",
|
||||
"--icu-data-dir=/home/trung/.nvm/versions/node/v16.20.2/lib/node_modules/full-icu",
|
||||
"--trace-warnings"
|
||||
],
|
||||
"program": "${workspaceFolder}/node_modules/.bin/_mocha",
|
||||
"args": [
|
||||
"--exit",
|
||||
"--require", "tests/setup.js",
|
||||
"${file}"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"envFile": "${workspaceFolder}/environment.env",
|
||||
"env": {
|
||||
"DEBUG": "agm:*"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
|
||||
664
Development/server/controllers/dashboard.js
Normal file
664
Development/server/controllers/dashboard.js
Normal file
@ -0,0 +1,664 @@
|
||||
'use strict';
|
||||
|
||||
const debug = require('debug')('agm:dashboard');
|
||||
const ObjectId = require('mongodb').ObjectId;
|
||||
const { Job, App, AppFile, AppDetail } = require('../model');
|
||||
const { JobStatus } = require('../helpers/job_constants');
|
||||
const { Errors } = require('../helpers/constants');
|
||||
const { AppAuthError, AppParamError, AppError } = require('../helpers/app_error');
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Job statuses shown in the Active Jobs panel. INVOICED (5) and ARCHIVED (9) are excluded. */
|
||||
const ACTIVE_JOB_STATUSES = [
|
||||
JobStatus.NEW,
|
||||
JobStatus.READY,
|
||||
JobStatus.DOWNLOADED,
|
||||
JobStatus.SPRAYED,
|
||||
JobStatus.COMPLETED
|
||||
];
|
||||
|
||||
/** XT cross-track error thresholds (meters). */
|
||||
const XT_GOOD = 1.0;
|
||||
const XT_MONITOR = 3.0;
|
||||
|
||||
/** Altitude thresholds (meters). Target ~3.7 m. */
|
||||
const ALT_TARGET = 3.7;
|
||||
const ALT_GOOD = 0.15; // ±0.15 m of target
|
||||
const ALT_MONITOR = 0.46; // ±0.46 m of target
|
||||
|
||||
// ─── Timezone helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate an IANA timezone string. Returns 'UTC' if invalid or missing.
|
||||
*/
|
||||
function validateTz(tz) {
|
||||
if (!tz || typeof tz !== 'string') return 'UTC';
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||
return tz;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a 'YYYY-MM-DD' date label for a Date in the given timezone.
|
||||
* Uses 'en-CA' locale which formats as YYYY-MM-DD natively.
|
||||
*/
|
||||
function toDateLabel(date, tz) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the UTC Date corresponding to midnight 00:00:00 of dateLabel in tz.
|
||||
*
|
||||
* Strategy: format noon-UTC as local time in tz (via Intl), parse that back as
|
||||
* UTC to measure the timezone offset, then apply that offset to naive midnight.
|
||||
* This handles DST correctly because noon on most days is not a DST boundary.
|
||||
*/
|
||||
function midnightUTC(dateLabel, tz) {
|
||||
const noonUTC = new Date(`${dateLabel}T12:00:00.000Z`);
|
||||
const localStr = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false
|
||||
}).format(noonUTC);
|
||||
// en-CA produces "YYYY-MM-DD, HH:mm:ss"
|
||||
const [datePart, timePart] = localStr.split(', ');
|
||||
const localNoon = new Date(`${datePart}T${timePart}Z`); // parse as UTC to get naive value
|
||||
const offsetMs = noonUTC.getTime() - localNoon.getTime();
|
||||
const naiveUTC = new Date(`${dateLabel}T00:00:00.000Z`);
|
||||
return new Date(naiveUTC.getTime() + offsetMs);
|
||||
}
|
||||
|
||||
/** { start, end } UTC range for a day that is daysAgo before today in tz (0 = today). */
|
||||
function dayWindow(daysAgo, tz) {
|
||||
const now = new Date();
|
||||
const todayLabel = toDateLabel(now, tz);
|
||||
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
|
||||
pivot.setUTCDate(pivot.getUTCDate() - daysAgo);
|
||||
const targetLabel = toDateLabel(pivot, tz);
|
||||
const start = midnightUTC(targetLabel, tz);
|
||||
const end = new Date(start.getTime() + 86400000);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** { start, end } UTC range for the current Mon–Sun calendar week in tz. */
|
||||
function weekWindow(tz) {
|
||||
const now = new Date();
|
||||
const todayLabel = toDateLabel(now, tz);
|
||||
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
|
||||
const sinceMonday = (pivot.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
|
||||
const monPivot = new Date(pivot);
|
||||
monPivot.setUTCDate(monPivot.getUTCDate() - sinceMonday);
|
||||
const sunPivot = new Date(monPivot);
|
||||
sunPivot.setUTCDate(sunPivot.getUTCDate() + 6);
|
||||
const start = midnightUTC(toDateLabel(monPivot, tz), tz);
|
||||
const end = new Date(midnightUTC(toDateLabel(sunPivot, tz), tz).getTime() + 86400000);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** { start, end } UTC range for the current calendar month in tz. */
|
||||
function monthWindow(tz) {
|
||||
const now = new Date();
|
||||
const [year, month] = toDateLabel(now, tz).split('-');
|
||||
const start = midnightUTC(`${year}-${month}-01`, tz);
|
||||
const end = new Date(start);
|
||||
end.setUTCMonth(end.getUTCMonth() + 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** { start, end } UTC range for the current calendar year in tz. */
|
||||
function yearWindow(tz) {
|
||||
const now = new Date();
|
||||
const [year] = toDateLabel(now, tz).split('-');
|
||||
const start = midnightUTC(`${year}-01-01`, tz);
|
||||
const end = new Date(start);
|
||||
end.setUTCFullYear(end.getUTCFullYear() + 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function round2(n) {
|
||||
return Math.round((n || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function safePct(current, previous) {
|
||||
if (!previous) return null;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/** Build Application base match for a set of jobIds (processed apps only). */
|
||||
function appMatch(jobIds) {
|
||||
return { jobId: { $in: jobIds }, status: 3, markedDelete: { $ne: true } };
|
||||
}
|
||||
|
||||
/** Fetch all pilot job documents (light projection). */
|
||||
async function fetchPilotJobs(pilotId) {
|
||||
return Job.find(
|
||||
{ operator: ObjectId(pilotId), markedDelete: { $ne: true } },
|
||||
{ _id: 1, ttSprArea: 1, status: 1, byPuid: 1, name: 1, client: 1, vehicle: 1, endDate: 1, createdAt: 1 }
|
||||
).lean();
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pilot/kpi ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @api {get} /api/dashboard/pilot/kpi Pilot KPI Cards
|
||||
* @apiName GetPilotKpi
|
||||
* @apiGroup PilotDashboard
|
||||
* @apiDescription Returns KPI card data for the authenticated pilot.
|
||||
* All Application metrics are based on createdDate (upload time) since
|
||||
* Application.endDateTime is a raw device string with no guaranteed format.
|
||||
*
|
||||
* @apiQuery {String} [tz=UTC] IANA timezone string for today/week/month/year boundaries.
|
||||
*
|
||||
* @apiSuccess {Number} assignedJobs All-time total jobs assigned to this pilot.
|
||||
* @apiSuccess {Number} assignedHectares Sum of ttSprArea across all assigned jobs.
|
||||
* @apiSuccess {Number} sprayedToday Hectares sprayed today (by upload time).
|
||||
* @apiSuccess {Number} flightHoursToday Flight hours recorded today.
|
||||
* @apiSuccess {Object} operations distanceKm and sprayVolumeLiters for today.
|
||||
* @apiSuccess {Object} historical Breakdown of jobs/hectares/flightHours by period.
|
||||
*/
|
||||
async function getKpi(req, res) {
|
||||
if (!req.uid) AppAuthError.throw();
|
||||
|
||||
const tz = validateTz(req.query.tz);
|
||||
const todayW = dayWindow(0, tz);
|
||||
const weekW = weekWindow(tz);
|
||||
const monthW = monthWindow(tz);
|
||||
const yearW = yearWindow(tz);
|
||||
|
||||
const jobs = await fetchPilotJobs(req.uid);
|
||||
const jobIds = jobs.map(j => j._id);
|
||||
const assignedJobs = jobs.length;
|
||||
const assignedHectares = round2(jobs.reduce((s, j) => s + (j.ttSprArea || 0), 0));
|
||||
|
||||
if (!jobIds.length) {
|
||||
return res.json({
|
||||
assignedJobs,
|
||||
assignedHectares,
|
||||
sprayedToday: 0,
|
||||
flightHoursToday: 0,
|
||||
operations: { distanceKm: 0, sprayVolumeLiters: 0 },
|
||||
historical: {
|
||||
jobs: { year: 0, month: 0, week: 0, day: 0 },
|
||||
hectares: { year: 0, month: 0, week: 0, day: 0 },
|
||||
flightHours: { year: 0, month: 0, week: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const base = appMatch(jobIds);
|
||||
|
||||
// Run all aggregations in parallel
|
||||
const [todayR, weekR, monthR, yearR, wJobs, mJobs, yJobs] = await Promise.all([
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, distance: { $sum: '$totalSprLength' }, volume: { $sum: '$totalSprayMat' } } }
|
||||
]),
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: weekW.start, $lt: weekW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||||
]),
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: monthW.start, $lt: monthW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||||
]),
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: yearW.start, $lt: yearW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||||
]),
|
||||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: weekW.start, $lt: weekW.end } }),
|
||||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: monthW.start, $lt: monthW.end } }),
|
||||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: yearW.start, $lt: yearW.end } })
|
||||
]);
|
||||
|
||||
const t = todayR[0] || {}, w = weekR[0] || {}, m = monthR[0] || {}, y = yearR[0] || {};
|
||||
|
||||
res.json({
|
||||
assignedJobs,
|
||||
assignedHectares,
|
||||
sprayedToday: round2(t.sprayed || 0),
|
||||
flightHoursToday: round2((t.flightTime || 0) / 3600),
|
||||
operations: {
|
||||
distanceKm: round2((t.distance || 0) / 1000),
|
||||
sprayVolumeLiters: round2(t.volume || 0)
|
||||
},
|
||||
historical: {
|
||||
jobs: { year: yJobs, month: mJobs, week: wJobs, day: assignedJobs },
|
||||
hectares: { year: round2(y.sprayed || 0), month: round2(m.sprayed || 0), week: round2(w.sprayed || 0), day: round2(t.sprayed || 0) },
|
||||
flightHours: { year: round2((y.flightTime || 0) / 3600), month: round2((m.flightTime || 0) / 3600), week: round2((w.flightTime || 0) / 3600), day: round2((t.flightTime || 0) / 3600) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pilot/summary ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @api {get} /api/dashboard/pilot/summary Pilot Daily Summary
|
||||
* @apiName GetPilotSummary
|
||||
* @apiGroup PilotDashboard
|
||||
* @apiDescription Returns today vs yesterday operational metrics with percentage deltas.
|
||||
*
|
||||
* @apiQuery {String} [tz=UTC] IANA timezone string.
|
||||
*/
|
||||
async function getSummary(req, res) {
|
||||
if (!req.uid) AppAuthError.throw();
|
||||
|
||||
const tz = validateTz(req.query.tz);
|
||||
const todayW = dayWindow(0, tz);
|
||||
const yesterW = dayWindow(1, tz);
|
||||
|
||||
const jobs = await fetchPilotJobs(req.uid);
|
||||
const jobIds = jobs.map(j => j._id);
|
||||
|
||||
const empty = { hectares: 0, flightHours: 0, haPerHour: 0, avgSpeedKmh: 0, sprayVolumeLiters: 0 };
|
||||
|
||||
if (!jobIds.length) {
|
||||
return res.json({ today: empty, yesterday: empty, deltas: { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null } });
|
||||
}
|
||||
|
||||
const base = appMatch(jobIds);
|
||||
|
||||
const [todayR, yesterR] = await Promise.all([
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
|
||||
]),
|
||||
App.aggregate([
|
||||
{ $match: { ...base, createdDate: { $gte: yesterW.start, $lt: yesterW.end } } },
|
||||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
|
||||
])
|
||||
]);
|
||||
|
||||
function buildDay(r) {
|
||||
const raw = r[0] || {};
|
||||
const hours = round2((raw.flightTime || 0) / 3600);
|
||||
const ha = round2(raw.sprayed || 0);
|
||||
const haPerHour = hours > 0 ? round2(ha / hours) : 0;
|
||||
return {
|
||||
hectares: ha,
|
||||
flightHours: hours,
|
||||
haPerHour,
|
||||
avgSpeedKmh: round2((raw.avgSpeed || 0) * 3.6),
|
||||
sprayVolumeLiters: round2(raw.volume || 0)
|
||||
};
|
||||
}
|
||||
|
||||
const today = buildDay(todayR);
|
||||
const yesterday = buildDay(yesterR);
|
||||
|
||||
res.json({
|
||||
today,
|
||||
yesterday,
|
||||
deltas: {
|
||||
hectaresPct: safePct(today.hectares, yesterday.hectares),
|
||||
flightHoursPct: safePct(today.flightHours, yesterday.flightHours),
|
||||
haPerHourPct: safePct(today.haPerHour, yesterday.haPerHour),
|
||||
avgSpeedPct: safePct(today.avgSpeedKmh, yesterday.avgSpeedKmh),
|
||||
sprayVolumePct: safePct(today.sprayVolumeLiters, yesterday.sprayVolumeLiters)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pilot/trend ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @api {get} /api/dashboard/pilot/trend Pilot Trend Charts Data
|
||||
* @apiName GetPilotTrend
|
||||
* @apiGroup PilotDashboard
|
||||
* @apiDescription Returns daily hours flown and hectares sprayed for a date range.
|
||||
* Defaults to the current calendar week (Mon–Sun). Missing days are filled with 0.
|
||||
*
|
||||
* @apiQuery {String} [tz=UTC] IANA timezone string.
|
||||
* @apiQuery {String} [startDate] YYYY-MM-DD start date (inclusive). Defaults to Monday of current week.
|
||||
* @apiQuery {String} [endDate] YYYY-MM-DD end date (inclusive). Defaults to Sunday of current week.
|
||||
*/
|
||||
async function getTrend(req, res) {
|
||||
if (!req.uid) AppAuthError.throw();
|
||||
|
||||
const tz = validateTz(req.query.tz);
|
||||
|
||||
let startDate = req.query.startDate;
|
||||
let endDate = req.query.endDate;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
const ww = weekWindow(tz);
|
||||
startDate = toDateLabel(ww.start, tz);
|
||||
// ww.end is exclusive (start of next Mon), so subtract 1 ms to get Sun
|
||||
endDate = toDateLabel(new Date(ww.end.getTime() - 1), tz);
|
||||
}
|
||||
|
||||
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRe.test(startDate) || !dateRe.test(endDate)) AppParamError.throw();
|
||||
|
||||
const startUTC = midnightUTC(startDate, tz);
|
||||
const endExcl = new Date(midnightUTC(endDate, tz).getTime() + 86400000);
|
||||
const diffDays = Math.round((endExcl - startUTC) / 86400000);
|
||||
if (diffDays < 1 || diffDays > 90) AppParamError.throw();
|
||||
|
||||
const jobs = await fetchPilotJobs(req.uid);
|
||||
const jobIds = jobs.map(j => j._id);
|
||||
|
||||
// Generate all date labels for the range
|
||||
const labels = [];
|
||||
const pivot = new Date(`${startDate}T12:00:00.000Z`);
|
||||
while (true) {
|
||||
const label = toDateLabel(pivot, tz);
|
||||
labels.push(label);
|
||||
if (label === endDate) break;
|
||||
pivot.setUTCDate(pivot.getUTCDate() + 1);
|
||||
if (labels.length > 91) break; // safety cap
|
||||
}
|
||||
|
||||
if (!jobIds.length) {
|
||||
return res.json({ labels, hoursFlown: labels.map(() => 0), hectaresPerDay: labels.map(() => 0) });
|
||||
}
|
||||
|
||||
const grouped = await App.aggregate([
|
||||
{
|
||||
$match: {
|
||||
...appMatch(jobIds),
|
||||
createdDate: { $gte: startUTC, $lt: endExcl }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdDate', timezone: tz } },
|
||||
hoursFlown: { $sum: '$totalFlightTime' },
|
||||
hectaresPerDay: { $sum: '$totalSprayed' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const dataMap = {};
|
||||
for (const r of grouped) {
|
||||
dataMap[r._id] = { hoursFlown: round2(r.hoursFlown / 3600), hectaresPerDay: round2(r.hectaresPerDay) };
|
||||
}
|
||||
|
||||
const hoursFlown = labels.map(l => (dataMap[l] || {}).hoursFlown || 0);
|
||||
const hectaresPerDay = labels.map(l => (dataMap[l] || {}).hectaresPerDay || 0);
|
||||
|
||||
res.json({ labels, hoursFlown, hectaresPerDay });
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pilot/activeJobs ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Display status groupings used by the frontend color/badge system.
|
||||
* NEW (0) → 'NEW'
|
||||
* READY/DOWNLOADED/SPRAYED → 'IN_PROGRESS'
|
||||
* COMPLETED (4) → 'COMPLETED'
|
||||
*/
|
||||
function toDisplayStatus(status) {
|
||||
if (status === JobStatus.NEW) return 'NEW';
|
||||
if (status === JobStatus.COMPLETED) return 'COMPLETED';
|
||||
return 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /api/dashboard/pilot/activeJobs Pilot Active Jobs Panel
|
||||
* @apiName GetPilotActiveJobs
|
||||
* @apiGroup PilotDashboard
|
||||
* @apiDescription Returns the pilot's assigned jobs (active statuses only) with per-job
|
||||
* sprayed hectares and applied volume aggregated from uploaded Application files.
|
||||
* INVOICED (5) and ARCHIVED (9) jobs are excluded.
|
||||
*/
|
||||
async function getActiveJobs(req, res) {
|
||||
if (!req.uid) AppAuthError.throw();
|
||||
|
||||
const jobs = await Job.aggregate([
|
||||
{
|
||||
$match: {
|
||||
operator: ObjectId(req.uid),
|
||||
markedDelete: { $ne: true },
|
||||
status: { $in: ACTIVE_JOB_STATUSES }
|
||||
}
|
||||
},
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $limit: 50 },
|
||||
// Client name (stored in users collection)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'client',
|
||||
foreignField: '_id',
|
||||
as: 'clientDoc'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$clientDoc', preserveNullAndEmptyArrays: true } },
|
||||
// Aircraft registration (Vehicle is a User discriminator stored in users collection)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'vehicle',
|
||||
foreignField: '_id',
|
||||
as: 'vehicleDoc'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$vehicleDoc', preserveNullAndEmptyArrays: true } },
|
||||
// Per-job aggregated Application totals
|
||||
{
|
||||
$lookup: {
|
||||
from: 'applications',
|
||||
let: { jobId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: {
|
||||
$and: [
|
||||
{ $eq: ['$jobId', '$$jobId'] },
|
||||
{ $eq: ['$status', 3] },
|
||||
{ $ne: ['$markedDelete', true] }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
haSprayed: { $sum: '$totalSprayed' },
|
||||
volumeApplied: { $sum: '$totalSprayMat' }
|
||||
}
|
||||
}
|
||||
],
|
||||
as: 'appTotals'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$appTotals', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
status: 1,
|
||||
haTotal: '$ttSprArea',
|
||||
clientName: '$clientDoc.name',
|
||||
aircraftReg: { $ifNull: ['$vehicleDoc.tailNumber', '$vehicleDoc.unitId'] },
|
||||
haSprayed: { $ifNull: ['$appTotals.haSprayed', 0] },
|
||||
volumeAppliedLiters: { $ifNull: ['$appTotals.volumeApplied', 0] }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const result = jobs.map(j => {
|
||||
const haSprayed = round2(j.haSprayed);
|
||||
const haTotal = round2(j.haTotal || 0);
|
||||
const progressPct = haTotal > 0 ? Math.min(100, Math.max(0, Math.round((haSprayed / haTotal) * 100))) : 0;
|
||||
return {
|
||||
jobId: j._id,
|
||||
name: j.name || '',
|
||||
clientName: j.clientName || '',
|
||||
aircraftReg: j.aircraftReg || '',
|
||||
status: j.status,
|
||||
displayStatus: toDisplayStatus(j.status),
|
||||
haTotal,
|
||||
haSprayed,
|
||||
progressPct,
|
||||
volumeAppliedLiters: round2(j.volumeAppliedLiters)
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ jobs: result });
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/pilot/performance ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @api {get} /api/dashboard/pilot/performance Pilot Performance Gauges
|
||||
* @apiName GetPilotPerformance
|
||||
* @apiGroup PilotDashboard
|
||||
* @apiDescription Returns average XT cross-track error and spray altitude gauges.
|
||||
* Aggregates over ApplicationDetail records for the pilot's last 10 processed
|
||||
* application files. Altitude source priority: sprayHeight (FM) → radarAlt (AGL).
|
||||
* Returns hasXtData / hasAltitudeData = false when no sensor data exists.
|
||||
*
|
||||
* NOTE: ApplicationDetail is a billion-document collection.
|
||||
* All queries are scoped by fileId to use the fileId index and avoid collection scans.
|
||||
*/
|
||||
async function getPerformance(req, res) {
|
||||
if (!req.uid) AppAuthError.throw();
|
||||
|
||||
const jobs = await fetchPilotJobs(req.uid);
|
||||
const jobIds = jobs.map(j => j._id);
|
||||
|
||||
const noData = {
|
||||
avgXtErrorMeters: null,
|
||||
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
|
||||
hasXtData: false,
|
||||
avgSprayAltitudeMeters: null,
|
||||
altitudeSource: null,
|
||||
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
|
||||
hasAltitudeData: false,
|
||||
sampleSize: 0
|
||||
};
|
||||
|
||||
if (!jobIds.length) return res.json(noData);
|
||||
|
||||
// Step 1: Get the 10 most recently uploaded processed Application records
|
||||
const recentApps = await App.find(appMatch(jobIds)).sort({ createdDate: -1 }).limit(10).select('_id').lean();
|
||||
if (!recentApps.length) return res.json(noData);
|
||||
|
||||
// Step 2: Get all AppFiles for those applications
|
||||
const appIds = recentApps.map(a => a._id);
|
||||
const appFiles = await AppFile.find(
|
||||
{ appId: { $in: appIds }, markedDelete: { $ne: true } },
|
||||
{ _id: 1 }
|
||||
).lean();
|
||||
|
||||
if (!appFiles.length) return res.json(noData);
|
||||
|
||||
const fileIds = appFiles.map(f => f._id);
|
||||
|
||||
// Step 3: Aggregate ApplicationDetail — uses fileId index only (no collection scan)
|
||||
const agg = await AppDetail.aggregate([
|
||||
{ $match: { fileId: { $in: fileIds } } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
// XT error: average of abs(xTrack) excluding zero (zero means "no reading")
|
||||
avgXtError: { $avg: { $cond: [{ $ne: ['$xTrack', 0] }, { $abs: '$xTrack' }, null] } },
|
||||
xtCount: { $sum: { $cond: [{ $ne: ['$xTrack', 0] }, 1, 0] } },
|
||||
// sprayHeight: Flight Master dedicated sensor
|
||||
avgSprayHeight: { $avg: { $cond: [{ $gt: ['$sprayHeight', 0] }, '$sprayHeight', null] } },
|
||||
sprayHeightCount: { $sum: { $cond: [{ $gt: ['$sprayHeight', 0] }, 1, 0] } },
|
||||
// radarAlt: fallback AGL source
|
||||
avgRadarAlt: { $avg: { $cond: [{ $gt: ['$radarAlt', 0] }, '$radarAlt', null] } },
|
||||
radarAltCount: { $sum: { $cond: [{ $gt: ['$radarAlt', 0] }, 1, 0] } }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const r = agg[0];
|
||||
if (!r) return res.json({ ...noData, sampleSize: fileIds.length });
|
||||
|
||||
// Altitude: prefer sprayHeight over radarAlt
|
||||
let avgSprayAltitudeMeters = null;
|
||||
let altitudeSource = null;
|
||||
let hasAltitudeData = false;
|
||||
|
||||
if (r.sprayHeightCount > 0 && r.avgSprayHeight != null) {
|
||||
avgSprayAltitudeMeters = round2(r.avgSprayHeight);
|
||||
altitudeSource = 'sprayHeight';
|
||||
hasAltitudeData = true;
|
||||
} else if (r.radarAltCount > 0 && r.avgRadarAlt != null) {
|
||||
avgSprayAltitudeMeters = round2(r.avgRadarAlt);
|
||||
altitudeSource = 'radarAlt';
|
||||
hasAltitudeData = true;
|
||||
}
|
||||
|
||||
const hasXtData = r.xtCount > 0 && r.avgXtError != null;
|
||||
const avgXtErrorMeters = hasXtData ? round2(r.avgXtError) : null;
|
||||
|
||||
res.json({
|
||||
avgXtErrorMeters,
|
||||
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
|
||||
hasXtData,
|
||||
avgSprayAltitudeMeters,
|
||||
altitudeSource,
|
||||
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
|
||||
hasAltitudeData,
|
||||
sampleSize: fileIds.length
|
||||
});
|
||||
}
|
||||
|
||||
// ─── PATCH /api/jobs/:job_id/complete ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @api {patch} /api/jobs/:job_id/complete Mark Job as Completed
|
||||
* @apiName CompleteJob
|
||||
* @apiGroup Jobs
|
||||
* @apiDescription Transitions a job from SPRAYED (3) to COMPLETED (4).
|
||||
* Only the Applicator who owns the job (Job.byPuid) may perform this action.
|
||||
* Returns 409 if the job is not in SPRAYED status. Returns 401 if the caller
|
||||
* is not the job owner.
|
||||
*
|
||||
* @apiParam {Number} job_id The numeric job ID.
|
||||
*
|
||||
* @apiSuccess {Object} job The updated job document.
|
||||
*
|
||||
* @apiError (409) {Object} error Status is not SPRAYED, or job does not exist.
|
||||
* @apiError (401) {Object} error Caller is not the job owner.
|
||||
*/
|
||||
async function completeJob(req, res) {
|
||||
const jobId = Number(req.params.job_id);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) AppParamError.throw();
|
||||
|
||||
const job = await Job.findById(jobId, { status: 1, byPuid: 1 }).lean();
|
||||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||||
|
||||
// Only the Applicator who owns the job may complete it
|
||||
if (!job.byPuid || job.byPuid.toString() !== req.uid) AppAuthError.throw();
|
||||
|
||||
// Transition is only valid from SPRAYED (3)
|
||||
if (job.status !== JobStatus.SPRAYED) AppParamError.throw(Errors.STATUS_JOB_INVALID);
|
||||
|
||||
const updated = await Job.findByIdAndUpdate(
|
||||
jobId,
|
||||
{ $set: { status: JobStatus.COMPLETED } },
|
||||
{ new: true, lean: true }
|
||||
)
|
||||
.populate({ path: 'client', select: 'name' })
|
||||
.populate({ path: 'operator', select: 'name' })
|
||||
.populate({ path: 'vehicle', select: 'name tailNumber unitId' });
|
||||
|
||||
if (!updated) AppError.throw(Errors.JOB_NOT_FOUND);
|
||||
|
||||
debug('Job %d marked COMPLETED by %s', jobId, req.uid);
|
||||
res.json(updated);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getKpi,
|
||||
getSummary,
|
||||
getTrend,
|
||||
getActiveJobs,
|
||||
getPerformance,
|
||||
completeJob
|
||||
};
|
||||
@ -1,6 +1,11 @@
|
||||
# AgMission Server Documentation Index
|
||||
|
||||
**Last Updated**: February 27, 2026
|
||||
**Last Updated**: April 29, 2026
|
||||
|
||||
## Analytics Dashboard
|
||||
|
||||
- [PILOT_DASHBOARD_API.md](./PILOT_DASHBOARD_API.md) ★ Full API design and frontend integration reference (primary reference)
|
||||
- [Pilot_Dashboard_API.postman_collection.json](./Pilot_Dashboard_API.postman_collection.json) — Postman collection (Login → all 5 dashboard endpoints + 3 complete-job scenarios)
|
||||
|
||||
## Partner Integration
|
||||
|
||||
|
||||
722
Development/server/docs/PILOT_DASHBOARD_API.md
Normal file
722
Development/server/docs/PILOT_DASHBOARD_API.md
Normal file
@ -0,0 +1,722 @@
|
||||
# Pilot Analytics Dashboard — API Design Reference
|
||||
|
||||
**Version**: 1.0 — Phase 1 (2026-04-29)
|
||||
**Scope**: Backend API contract for the Pilot Analytics Dashboard.
|
||||
This document is the single source of truth for **both backend and frontend/client** development.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1 Overview](#1-overview)
|
||||
- [2 Authentication](#2-authentication)
|
||||
- [3 Status Constants](#3-status-constants)
|
||||
- [4 Timezone Handling](#4-timezone-handling)
|
||||
- [5 Endpoints](#5-endpoints)
|
||||
- [5 1 KPI Cards](#5-1-kpi-cards)
|
||||
- [5 2 Daily Summary](#5-2-daily-summary)
|
||||
- [5 3 Trend Charts](#5-3-trend-charts)
|
||||
- [5 4 Active Jobs Panel](#5-4-active-jobs-panel)
|
||||
- [5 5 Performance Gauges](#5-5-performance-gauges)
|
||||
- [5 6 Mark Job as Completed](#5-6-mark-job-as-completed)
|
||||
- [6 Error Responses](#6-error-responses)
|
||||
- [7 Data Model Notes](#7-data-model-notes)
|
||||
- [8 Frontend Integration Guide](#8-frontend-integration-guide)
|
||||
- [9 Backend Architecture Notes](#9-backend-architecture-notes)
|
||||
- [9 1 Pilot Scope Data Flow Diagram](#9-1-pilot-scope-data-flow-diagram)
|
||||
- [9 2 Endpoint Interaction Diagram](#9-2-endpoint-interaction-diagram)
|
||||
- [9 3 Performance Query Safety Diagram](#9-3-performance-query-safety-diagram)
|
||||
- [10 Open Decisions](#10-open-decisions)
|
||||
|
||||
---
|
||||
|
||||
## 1 Overview
|
||||
|
||||
The Pilot Dashboard is a read-only analytics surface (plus one write action) scoped to the
|
||||
**currently authenticated Pilot user**. All endpoints derive the pilot identity from the
|
||||
JWT token — no client-side user ID parameter is accepted or trusted.
|
||||
|
||||
**Base path**: `/api/dashboard/pilot`
|
||||
|
||||
**Implemented in**:
|
||||
|
||||
- `controllers/dashboard.js`
|
||||
- `routes/dashboard.js`
|
||||
- `routes/job.js` (for the complete action)
|
||||
|
||||
**Testing resources**:
|
||||
|
||||
- Postman collection: `docs/Pilot_Dashboard_API.postman_collection.json`
|
||||
- Node.js test script: `tests/test_pilot_dashboard_api.js`
|
||||
|
||||
**All dashboard endpoints share the same scoping logic**:
|
||||
|
||||
```
|
||||
pilot → Job.operator = req.uid → jobIds → Application.jobId IN jobIds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2 Authentication
|
||||
|
||||
All routes require a valid JWT bearer token. The `checkUser` middleware is applied globally
|
||||
in `server.js` before any route is mounted.
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
The token payload sets `req.uid` (user ID string) and `req.userInfo` on every request.
|
||||
Dashboard endpoints check `req.uid` and throw `401 Not Authorized` if absent.
|
||||
|
||||
The dashboard route group (`/api/dashboard`) does **not** require a subscription package
|
||||
(`checkRqPkgSubscription` is not applied there). The complete-job endpoint lives under
|
||||
`/api/jobs` which **does** apply that middleware.
|
||||
|
||||
---
|
||||
|
||||
## 3 Status Constants
|
||||
|
||||
### Job Status (`helpers/job_constants.js`)
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|--------------|-------|----------------------------------------------|
|
||||
| `NEW` | `0` | Job created, not yet prepared |
|
||||
| `READY` | `1` | Prepared and available for aircraft download |
|
||||
| `DOWNLOADED` | `2` | Downloaded to aircraft |
|
||||
| `SPRAYED` | `3` | Application data uploaded, not yet reviewed |
|
||||
| `COMPLETED` | `4` | Reviewed and marked done by Applicator |
|
||||
| `INVOICED` | `5` | Invoice issued |
|
||||
| `ARCHIVED` | `9` | Archived |
|
||||
|
||||
### Display Status (frontend label mapping)
|
||||
|
||||
The `activeJobs` endpoint returns a `displayStatus` string derived from the raw numeric status:
|
||||
|
||||
| `status` value(s) | `displayStatus` | Suggested UI treatment |
|
||||
|--------------------|-----------------|-----------------------------------|
|
||||
| `0` (NEW) | `"NEW"` | Grey badge, no progress bar |
|
||||
| `1`, `2`, `3` | `"IN_PROGRESS"` | Blue/active badge, show progress |
|
||||
| `4` (COMPLETED) | `"COMPLETED"` | Green badge, full bar |
|
||||
|
||||
> INVOICED (`5`) and ARCHIVED (`9`) are excluded from the active jobs panel entirely.
|
||||
|
||||
### Application Status
|
||||
|
||||
Only `status: 3` (processed) applications are counted in all aggregations. Uploading (`1`),
|
||||
in-progress (`2`), and error (`0`) applications are excluded automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4 Timezone Handling
|
||||
|
||||
All time-windowed endpoints accept an optional `?tz=<IANA>` query parameter.
|
||||
|
||||
- **Default**: `UTC`
|
||||
- **Example**: `?tz=America/Sao_Paulo`, `?tz=America/Chicago`, `?tz=Australia/Brisbane`
|
||||
- **Invalid values** silently fall back to `UTC` — the frontend should always send a valid tz.
|
||||
|
||||
**What timezone affects**:
|
||||
- "Today" and "yesterday" day boundaries
|
||||
- Current week (Mon–Sun) boundaries
|
||||
- Current month and year boundaries
|
||||
- Date labels in the trend chart (`YYYY-MM-DD`)
|
||||
|
||||
**What timezone does NOT affect**:
|
||||
- Raw UTC timestamps stored in MongoDB
|
||||
- Job `createdAt` (always UTC)
|
||||
|
||||
**Recommended frontend behaviour**: read the browser's timezone once (`Intl.DateTimeFormat().resolvedOptions().timeZone`) and pass it on every dashboard request.
|
||||
|
||||
---
|
||||
|
||||
## 5 Endpoints
|
||||
|
||||
### 5 1 KPI Cards
|
||||
|
||||
Returns top-level KPI cards and historical breakdowns for the authenticated pilot.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|--------|---------|------------------------------------|
|
||||
| `tz` | String | `UTC` | IANA timezone for period windows |
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"assignedJobs": 42,
|
||||
"assignedHectares": 1850.40,
|
||||
"sprayedToday": 120.50,
|
||||
"flightHoursToday": 3.25,
|
||||
"operations": {
|
||||
"distanceKm": 345.80,
|
||||
"sprayVolumeLiters": 2400.00
|
||||
},
|
||||
"historical": {
|
||||
"jobs": {
|
||||
"year": 38,
|
||||
"month": 12,
|
||||
"week": 4,
|
||||
"day": 42
|
||||
},
|
||||
"hectares": {
|
||||
"year": 1600.00,
|
||||
"month": 420.00,
|
||||
"week": 180.00,
|
||||
"day": 120.50
|
||||
},
|
||||
"flightHours": {
|
||||
"year": 210.50,
|
||||
"month": 52.75,
|
||||
"week": 18.25,
|
||||
"day": 3.25
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Notes
|
||||
|
||||
| Field | Unit | Notes |
|
||||
|-------------------------------|-----------|-------------------------------------------------|
|
||||
| `assignedJobs` | count | All-time total jobs where `Job.operator = uid` |
|
||||
| `assignedHectares` | ha | Sum of `Job.ttSprArea` across all assigned jobs |
|
||||
| `sprayedToday` | ha | Sum of `Application.totalSprayed` today |
|
||||
| `flightHoursToday` | hours | Sum of `Application.totalFlightTime` / 3600 |
|
||||
| `operations.distanceKm` | km | Sum of `Application.totalSprLength` / 1000 |
|
||||
| `operations.sprayVolumeLiters`| litres | Sum of `Application.totalSprayMat` |
|
||||
| `historical.jobs.day` | count | Same as `assignedJobs` (all-time) |
|
||||
| `historical.jobs.week/month/year` | count | Jobs created within the period (by `createdAt`) |
|
||||
|
||||
All numeric values are rounded to 2 decimal places.
|
||||
|
||||
---
|
||||
|
||||
### 5 2 Daily Summary
|
||||
|
||||
Returns today vs. yesterday operational metrics with percentage change deltas.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|--------|---------|----------------------------------|
|
||||
| `tz` | String | `UTC` | IANA timezone for day boundaries |
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"today": {
|
||||
"hectares": 120.50,
|
||||
"flightHours": 3.25,
|
||||
"haPerHour": 37.08,
|
||||
"avgSpeedKmh": 28.50,
|
||||
"sprayVolumeLiters": 2400.00
|
||||
},
|
||||
"yesterday": {
|
||||
"hectares": 95.00,
|
||||
"flightHours": 2.80,
|
||||
"haPerHour": 33.93,
|
||||
"avgSpeedKmh": 26.10,
|
||||
"sprayVolumeLiters": 1900.00
|
||||
},
|
||||
"deltas": {
|
||||
"hectaresPct": 27,
|
||||
"flightHoursPct": 16,
|
||||
"haPerHourPct": 9,
|
||||
"avgSpeedPct": 9,
|
||||
"sprayVolumePct": 26
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Notes
|
||||
|
||||
| Field | Unit | Notes |
|
||||
|---------------------------------|---------|-----------------------------------------------------------|
|
||||
| `today.haPerHour` | ha/hr | Derived: `hectares / flightHours` (0 if no flight time) |
|
||||
| `today.avgSpeedKmh` | km/h | `Application.avgSpraySpeed` (m/s) × 3.6, averaged |
|
||||
| `deltas.*Pct` | % | `round((today − yesterday) / yesterday × 100)` |
|
||||
| `deltas.*Pct` = `null` | — | yesterday value was 0 (division by zero avoided) |
|
||||
|
||||
Positive delta = improvement today vs. yesterday. Negative = drop.
|
||||
|
||||
---
|
||||
|
||||
### 5 3 Trend Charts
|
||||
|
||||
Returns daily hours flown and hectares sprayed for a date range.
|
||||
Default range is the current calendar week (Monday–Sunday) in the given timezone.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-------------|--------|--------------------|--------------------------------------------------|
|
||||
| `tz` | String | `UTC` | IANA timezone for day grouping and boundaries |
|
||||
| `startDate` | String | Monday of this week| Start date inclusive. Format: `YYYY-MM-DD` |
|
||||
| `endDate` | String | Sunday of this week| End date inclusive. Format: `YYYY-MM-DD` |
|
||||
|
||||
- Maximum range: **90 days**. Returns `409` if exceeded.
|
||||
- Both `startDate` and `endDate` must be provided together, or both omitted.
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"labels": ["2026-04-27", "2026-04-28", "2026-04-29", "2026-04-30", "2026-05-01", "2026-05-02", "2026-05-03"],
|
||||
"hoursFlown": [2.50, 3.25, 0, 1.80, 0, 0, 0],
|
||||
"hectaresPerDay":[80.0, 120.5, 0, 65.0, 0, 0, 0]
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Notes
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-----------------|-----------------|----------------------------------------------------------|
|
||||
| `labels` | `String[]` | One entry per calendar day in `YYYY-MM-DD`, tz-adjusted |
|
||||
| `hoursFlown` | `Number[]` | Parallel array. Days with no activity = `0` |
|
||||
| `hectaresPerDay`| `Number[]` | Parallel array. Days with no activity = `0` |
|
||||
|
||||
**Array contract**: all three arrays are always the same length and in the same order.
|
||||
Frontend can zip them: `labels[i]` ↔ `hoursFlown[i]` ↔ `hectaresPerDay[i]`.
|
||||
|
||||
---
|
||||
|
||||
### 5 4 Active Jobs Panel
|
||||
|
||||
Returns the pilot's active-status jobs with per-job progress and applied totals.
|
||||
|
||||
- Statuses included: `NEW (0)`, `READY (1)`, `DOWNLOADED (2)`, `SPRAYED (3)`, `COMPLETED (4)`
|
||||
- Statuses excluded: `INVOICED (5)`, `ARCHIVED (9)`
|
||||
- Maximum 50 most recent jobs returned (sorted by `createdAt` descending)
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
None.
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"jobId": 1042,
|
||||
"name": "North Block — Canola",
|
||||
"clientName": "Sunrise Farms Ltd.",
|
||||
"aircraftReg": "C-FABM",
|
||||
"status": 3,
|
||||
"displayStatus": "IN_PROGRESS",
|
||||
"haTotal": 250.00,
|
||||
"haSprayed": 187.50,
|
||||
"progressPct": 75,
|
||||
"volumeAppliedLiters": 3750.00
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Notes
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-----------------------|---------|--------------------------------------------------------------------|
|
||||
| `jobId` | Number | Numeric job ID (`Job._id`, not a MongoDB ObjectId) |
|
||||
| `name` | String | Job name. Empty string if not set |
|
||||
| `clientName` | String | From linked `Client` user record. Empty if not linked |
|
||||
| `aircraftReg` | String | `Vehicle.tailNumber` or `Vehicle.unitId`. Empty if not linked |
|
||||
| `status` | Number | Raw numeric status (see constants table) |
|
||||
| `displayStatus` | String | `"NEW"` \| `"IN_PROGRESS"` \| `"COMPLETED"` |
|
||||
| `haTotal` | Number | `Job.ttSprArea` — planned area in ha. `0` if not set |
|
||||
| `haSprayed` | Number | Sum of `Application.totalSprayed` for this job (processed only) |
|
||||
| `progressPct` | Number | `0–100`. `0` if `haTotal` is 0 or no applications |
|
||||
| `volumeAppliedLiters` | Number | Sum of `Application.totalSprayMat` for this job |
|
||||
|
||||
**`progressPct` formula**: `min(100, max(0, round(haSprayed / haTotal × 100)))`
|
||||
This can exceed 100% theoretically if `haSprayed > haTotal`; the backend caps it at 100.
|
||||
|
||||
---
|
||||
|
||||
### 5 5 Performance Gauges
|
||||
|
||||
Returns average XT cross-track error and spray altitude gauges based on the
|
||||
pilot's last 10 processed application files.
|
||||
|
||||
> **Note**: ApplicationDetail is a billion-row collection. Queries are strictly
|
||||
> bounded to a set of `fileId` values to use the collection's primary index.
|
||||
> Never query this collection without a `fileId` filter.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
None.
|
||||
|
||||
#### Response `200 OK` — data available
|
||||
|
||||
```json
|
||||
{
|
||||
"avgXtErrorMeters": 1.85,
|
||||
"xtThreshold": {
|
||||
"good": 1.0,
|
||||
"monitor": 3.0
|
||||
},
|
||||
"hasXtData": true,
|
||||
|
||||
"avgSprayAltitudeMeters": 3.62,
|
||||
"altitudeSource": "sprayHeight",
|
||||
"altThreshold": {
|
||||
"target": 3.7,
|
||||
"goodBand": 0.15,
|
||||
"monitorBand": 0.46
|
||||
},
|
||||
"hasAltitudeData": true,
|
||||
"sampleSize": 24
|
||||
}
|
||||
```
|
||||
|
||||
#### Response `200 OK` — no data (new pilot, no apps uploaded)
|
||||
|
||||
```json
|
||||
{
|
||||
"avgXtErrorMeters": null,
|
||||
"xtThreshold": { "good": 1.0, "monitor": 3.0 },
|
||||
"hasXtData": false,
|
||||
|
||||
"avgSprayAltitudeMeters": null,
|
||||
"altitudeSource": null,
|
||||
"altThreshold": { "target": 3.7, "goodBand": 0.15, "monitorBand": 0.46 },
|
||||
"hasAltitudeData": false,
|
||||
"sampleSize": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### Field Notes
|
||||
|
||||
| Field | Unit | Notes |
|
||||
|---------------------------|--------|--------------------------------------------------------------------|
|
||||
| `avgXtErrorMeters` | metres | Average of `abs(ApplicationDetail.xTrack)`. `null` if no XT data |
|
||||
| `xtThreshold.good` | metres | Below this → green zone |
|
||||
| `xtThreshold.monitor` | metres | Above this → red zone; between good and monitor → yellow |
|
||||
| `hasXtData` | bool | `false` = no valid xTrack readings in sample (show "No data") |
|
||||
| `avgSprayAltitudeMeters` | metres | Average spray height from best available sensor. `null` if none |
|
||||
| `altitudeSource` | String | `"sprayHeight"` (FM dedicated sensor) or `"radarAlt"` (AGL). `null` if no data |
|
||||
| `altThreshold.target` | metres | Ideal spray height (~3.7 m) |
|
||||
| `altThreshold.goodBand` | metres | ±0.15 m of target → green |
|
||||
| `altThreshold.monitorBand`| metres | ±0.46 m of target → yellow; outside → red |
|
||||
| `hasAltitudeData` | bool | `false` = aircraft has no height sensor (show "No data") |
|
||||
| `sampleSize` | count | Number of AppFile records in the analysis window (up to ~20) |
|
||||
|
||||
#### Threshold Gauge Logic (frontend)
|
||||
|
||||
```
|
||||
XT Cross-track error:
|
||||
value < good → green (acceptable precision)
|
||||
good ≤ value < monitor → yellow (monitor)
|
||||
value ≥ monitor → red (needs attention)
|
||||
|
||||
Spray Altitude:
|
||||
|value - target| < goodBand → green
|
||||
|value - target| < monitorBand → yellow
|
||||
|value - target| ≥ monitorBand → red
|
||||
```
|
||||
|
||||
#### Altitude Source Priority
|
||||
|
||||
The backend selects the best available altitude sensor in this order:
|
||||
1. **`sprayHeight`** — dedicated FM spray height sensor (most accurate)
|
||||
2. **`radarAlt`** — radar altimeter (AGL, good fallback)
|
||||
3. **No data** — `hasAltitudeData: false`, `avgSprayAltitudeMeters: null`
|
||||
|
||||
> `gpsAlt` (AMSL) is **not** used for spray height — it measures altitude above sea level,
|
||||
> not above the crop canopy.
|
||||
|
||||
`xTrack = 0` values are excluded from the XT average because `0` means "no reading",
|
||||
not "perfectly on track".
|
||||
|
||||
---
|
||||
|
||||
### 5 6 Mark Job as Completed
|
||||
|
||||
Transitions a job from **SPRAYED (3)** to **COMPLETED (4)**.
|
||||
|
||||
This endpoint lives under `/api/jobs` (not `/api/dashboard`) and requires an active
|
||||
subscription package (enforced by `checkRqPkgSubscription` middleware).
|
||||
|
||||
#### URL Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------------------------|
|
||||
| `job_id` | Number | Numeric job ID |
|
||||
|
||||
#### Request Body
|
||||
|
||||
None.
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
Returns the full updated job document (with `client`, `operator`, and `vehicle` populated).
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": 1042,
|
||||
"name": "North Block — Canola",
|
||||
"status": 4,
|
||||
"client": { "_id": "...", "name": "Sunrise Farms Ltd." },
|
||||
"operator": { "_id": "...", "name": "Jane Pilot" },
|
||||
"vehicle": { "_id": "...", "name": "Agri-One", "tailNumber": "C-FABM" },
|
||||
"..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Authorization
|
||||
|
||||
Only the **Applicator** who owns the job (`Job.byPuid === req.uid`) may complete it.
|
||||
The Pilot (`Job.operator`) cannot call this endpoint.
|
||||
|
||||
#### Error Cases
|
||||
|
||||
| Condition | Status | Error tag |
|
||||
|----------------------------------------------|--------|-----------------------|
|
||||
| `job_id` is not a valid positive number | `409` | `invalid_param` |
|
||||
| Job not found | `409` | `job_not_found` |
|
||||
| Caller is not the job owner (`byPuid`) | `401` | `not_authorized` |
|
||||
| Job status is not `SPRAYED (3)` | `409` | `status_job_invalid` |
|
||||
|
||||
---
|
||||
|
||||
## 6 Error Responses
|
||||
|
||||
All errors follow the standard AgMission error format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
".tag": "error_constant_value",
|
||||
"message": "Human-readable detail (development mode only)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP Status | `.tag` value | When it occurs |
|
||||
|-------------|------------------------|------------------------------------------------------|
|
||||
| `401` | `not_authorized` | Missing/invalid JWT, or caller not the job owner |
|
||||
| `409` | `invalid_param` | Bad date format, range > 90 days, invalid job_id |
|
||||
| `409` | `job_not_found` | Job does not exist |
|
||||
| `409` | `status_job_invalid` | Job is not in required status for transition |
|
||||
|
||||
---
|
||||
|
||||
## 7 Data Model Notes
|
||||
|
||||
### Pilot Scoping
|
||||
|
||||
All Application metrics are scoped via the Job's `operator` field, not `byUser` on Application:
|
||||
|
||||
```
|
||||
Job.operator = pilotId → collect jobIds → Application.jobId IN jobIds
|
||||
```
|
||||
|
||||
`Application.byUser` is the master Applicator account — it is **not** the pilot. Never use it
|
||||
to scope pilot metrics.
|
||||
|
||||
### Application.createdDate
|
||||
|
||||
Time-window filtering uses `Application.createdDate` (the upload timestamp, a real MongoDB Date),
|
||||
**not** `Application.endDateTime`. The `endDateTime` field is a raw device string with no
|
||||
guaranteed format and is unreliable for date arithmetic.
|
||||
|
||||
### Job._id is a Number
|
||||
|
||||
`Job._id` is an auto-incrementing Number (via `mongoose-sequence`), not a MongoDB ObjectId.
|
||||
Frontend must treat `jobId` values as integers, not hex strings.
|
||||
|
||||
### Vehicle and Client are User discriminators
|
||||
|
||||
Both `Vehicle` (kind=`DEVICE`) and `Client` (kind=`CLIENT`) are stored in the `users` MongoDB
|
||||
collection. The `$lookup` in `activeJobs` joins against `users` for both.
|
||||
|
||||
### Models and Collections
|
||||
|
||||
| Model name in code | Collection | Key dashboard fields |
|
||||
|--------------------|-----------------------|-------------------------------------------------------------|
|
||||
| `Job` | `jobs` | `operator`, `byPuid`, `ttSprArea`, `status`, `client`, `vehicle` |
|
||||
| `App` (Application)| `applications` | `jobId`, `status`, `createdDate`, `totalSprayed`, `totalFlightTime`, `totalSprLength`, `totalSprayMat`, `avgSpraySpeed` |
|
||||
| `AppFile` | `appfiles` | `appId` |
|
||||
| `AppDetail` | `application_details` | `fileId`, `xTrack`, `sprayHeight`, `radarAlt` |
|
||||
|
||||
---
|
||||
|
||||
## 8 Frontend Integration Guide
|
||||
|
||||
### Suggested Fetch Strategy
|
||||
|
||||
Load the dashboard in two tiers to keep the initial paint fast:
|
||||
|
||||
**Tier 1 — above the fold (load in parallel on mount)**
|
||||
```
|
||||
GET /api/dashboard/pilot/kpi?tz=...
|
||||
GET /api/dashboard/pilot/summary?tz=...
|
||||
GET /api/dashboard/pilot/activeJobs
|
||||
```
|
||||
|
||||
**Tier 2 — charts and gauges (load after Tier 1 resolves or in parallel)**
|
||||
```
|
||||
GET /api/dashboard/pilot/trend?tz=...&startDate=...&endDate=...
|
||||
GET /api/dashboard/pilot/performance
|
||||
```
|
||||
|
||||
### Timezone Snippet
|
||||
|
||||
```javascript
|
||||
// Read once and reuse
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; // e.g. "America/Sao_Paulo"
|
||||
|
||||
const params = new URLSearchParams({ tz });
|
||||
fetch(`/api/dashboard/pilot/kpi?${params}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
```
|
||||
|
||||
### Empty State Handling
|
||||
|
||||
Every endpoint returns meaningful zero-filled data when the pilot has no jobs:
|
||||
- Number fields return `0`
|
||||
- Null fields (`avgXtErrorMeters`, `altitudeSource`) return `null`
|
||||
- Boolean guard fields (`hasXtData`, `hasAltitudeData`) return `false`
|
||||
- Array fields (`labels`, `hoursFlown`, `hectaresPerDay`) return empty `[]`
|
||||
|
||||
The frontend should check `hasXtData` and `hasAltitudeData` before rendering gauges, and
|
||||
render a "No data yet" placeholder instead of a gauge at `null`.
|
||||
|
||||
### Progress Bar Colour Logic
|
||||
|
||||
Use `displayStatus` from `activeJobs` for badge colour, and `progressPct` for bar width:
|
||||
|
||||
```
|
||||
displayStatus = "NEW" → grey badge, hide progress bar
|
||||
displayStatus = "IN_PROGRESS" → blue badge, show progress bar at progressPct%
|
||||
displayStatus = "COMPLETED" → green badge, show bar at 100%
|
||||
```
|
||||
|
||||
### Mark as Completed Button Visibility
|
||||
|
||||
The **Complete** action is restricted to the **Applicator** (`Job.byPuid`), not the Pilot.
|
||||
If the frontend is used by an Applicator who is reviewing a pilot's job, show the button only
|
||||
when the raw `status === 3` (SPRAYED). After a successful `PATCH`, update the job in local
|
||||
state to `status = 4`, `displayStatus = "COMPLETED"`.
|
||||
|
||||
### Trend Chart Date Pickers
|
||||
|
||||
- Pass `startDate`/`endDate` as `YYYY-MM-DD` strings in the user's local timezone.
|
||||
- Maximum selectable range: 90 days.
|
||||
- Default range on first render: current week (omit both parameters and let the API default).
|
||||
|
||||
### Gauge Rendering Reference
|
||||
|
||||
```
|
||||
XT Error gauge (lower is better):
|
||||
0 m ──────── 1.0 m ──────────── 3.0 m ──────────→
|
||||
green yellow red
|
||||
↑ good ↑ monitor
|
||||
|
||||
Altitude gauge (target 3.7 m):
|
||||
← red ── 3.24 ── yellow ── 3.55 ── [3.7] ── 3.85 ── yellow ── 4.16 ── red →
|
||||
↑ -0.46 ↑ -0.15 target ↑ +0.15 ↑ +0.46
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9 Backend Architecture Notes
|
||||
|
||||
### 9 1 Pilot Scope Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Authenticated User req uid] --> B[Job Query operator equals uid]
|
||||
B --> C[Collect Job IDs]
|
||||
C --> D[Application Query jobId in Job IDs and status 3]
|
||||
D --> E[KPI Summary Trend Active Jobs Aggregates]
|
||||
```
|
||||
|
||||
### 9 2 Endpoint Interaction Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
FE[Frontend Pilot Dashboard] --> K[GET pilot kpi]
|
||||
FE --> S[GET pilot summary]
|
||||
FE --> T[GET pilot trend]
|
||||
FE --> A[GET pilot activeJobs]
|
||||
FE --> P[GET pilot performance]
|
||||
FE --> C[PATCH jobs job_id complete]
|
||||
|
||||
K --> J[(jobs)]
|
||||
K --> AP[(applications)]
|
||||
S --> AP
|
||||
T --> AP
|
||||
A --> J
|
||||
A --> AP
|
||||
P --> AP
|
||||
P --> AF[(appfiles)]
|
||||
P --> AD[(application_details)]
|
||||
C --> J
|
||||
```
|
||||
|
||||
### 9 3 Performance Query Safety Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Scoped Job IDs] --> B[Recent Processed Applications limit 10]
|
||||
B --> C[AppFile Lookup by appId]
|
||||
C --> D[ApplicationDetail Match by fileId in list]
|
||||
D --> E[Aggregate XT and Altitude Metrics]
|
||||
```
|
||||
|
||||
### Why Job.operator and not Application.byUser
|
||||
|
||||
`Application.byUser` is the master Applicator account that uploaded the file — not the
|
||||
pilot assigned to fly the job. Using it would mix data from all jobs the Applicator manages,
|
||||
not just those assigned to this pilot. The correct field is `Job.operator` (set when a pilot
|
||||
is assigned to a job).
|
||||
|
||||
### Why Application.createdDate and not endDateTime
|
||||
|
||||
`Application.endDateTime` is a raw string as received from the aircraft firmware. Its format
|
||||
varies by firmware version and is not reliable for MongoDB date range queries. `createdDate`
|
||||
is a `Date` field set by the server at upload time and is consistent across all records.
|
||||
|
||||
### ApplicationDetail Query Safety
|
||||
|
||||
`application_details` is a very large collection (potentially billions of rows). The only
|
||||
index available for dashboard use is `{ fileId: 1 }`. All performance queries follow a
|
||||
three-step pattern to avoid collection scans:
|
||||
|
||||
```
|
||||
1. App.find({ jobId: { $in: jobIds } }).limit(10) → recent appIds
|
||||
2. AppFile.find({ appId: { $in: appIds } }) → fileIds
|
||||
3. AppDetail.aggregate([{ $match: { fileId: { $in: fileIds } } }, ...])
|
||||
```
|
||||
|
||||
Never add additional `ApplicationDetail` queries without scoping by `fileId` first.
|
||||
|
||||
### Unit Conversions (done server-side)
|
||||
|
||||
| Raw storage | Conversion | API field |
|
||||
|------------------|--------------------|------------------------------|
|
||||
| `totalFlightTime`| seconds → hours ÷3600 | `flightHours`, `hoursFlown` |
|
||||
| `totalSprLength` | metres → km ÷1000 | `operations.distanceKm` |
|
||||
| `avgSpraySpeed` | m/s → km/h ×3.6 | `avgSpeedKmh` |
|
||||
|
||||
All other fields (`totalSprayed`, `totalSprayMat`, `xTrack`, `sprayHeight`, `radarAlt`)
|
||||
are stored and returned in their natural units (ha, L, m).
|
||||
|
||||
---
|
||||
|
||||
## 10 Open Decisions
|
||||
|
||||
These items affect backend behaviour but have not been finalised by the Product Owner.
|
||||
They are Phase 2 scope and the current implementation uses the defaults noted.
|
||||
|
||||
| ID | Question | Current default |
|
||||
|--------|--------------------------------------------------------------------------|----------------------------------------------|
|
||||
| KPI-1 | Does "Assigned Jobs" count all-time or just open/current-season jobs? | All-time |
|
||||
| ACT-1 | Should INVOICED (5) jobs appear in the Active Jobs panel? | Excluded |
|
||||
| Q11 | Can the Pilot trigger the complete action, or Applicator-only? | Applicator (`byPuid`) only |
|
||||
| PERF-1 | When no altitude sensor exists, should the gauge show N/A or be hidden? | Returns `hasAltitudeData: false`, value null |
|
||||
| JOBS-1 | Are jobs with `operator = null` (unassigned) relevant to any view? | Not included in any pilot-scoped query |
|
||||
193
Development/server/docs/PILOT_DASHBOARD_BACKEND_SUMMARY.md
Normal file
193
Development/server/docs/PILOT_DASHBOARD_BACKEND_SUMMARY.md
Normal file
@ -0,0 +1,193 @@
|
||||
# Pilot Dashboard Backend Summary
|
||||
|
||||
> **Superseded** — This document was the initial design analysis and planning doc.
|
||||
> The definitive API specification (including frontend integration guide) is now in:
|
||||
> **[PILOT_DASHBOARD_API.md](./PILOT_DASHBOARD_API.md)**
|
||||
>
|
||||
> The content below is retained for historical context (original requirements analysis,
|
||||
> open decisions log, and delivery plan).
|
||||
|
||||
---
|
||||
|
||||
## Document Info
|
||||
- Scope: Backend support for Pilot Analytics Dashboard (Phase 1)
|
||||
- Source inputs: Product Owner brief v1.5 and Requirements Analysis v1.5
|
||||
- Validation status: aligned with current server codebase (models, constants, routes, middleware)
|
||||
- Date: 2026-04-29
|
||||
|
||||
## 1. Objective
|
||||
Build backend APIs and aggregation logic for a pilot-only analytics dashboard, using existing AgMission data (Job, Application, Application_Detail), with strict role/data isolation and production-safe query patterns.
|
||||
|
||||
## 2. Current Backend Baseline (Verified)
|
||||
|
||||
### 2.1 What already exists
|
||||
- Pilot CRUD/search routes exist at /api/pilots via routes/pilot.js and controllers/pilot.js.
|
||||
- Global route registration follows function-based mounting in routes/index.js.
|
||||
- Auth middleware checkUser is applied globally before route mounting in server.js.
|
||||
- Standard centralized error handling is already in place.
|
||||
|
||||
### 2.2 What does not exist yet
|
||||
- No dashboard analytics controller/routes for pilots.
|
||||
- No endpoints currently serving KPI/summary/trend/active-jobs/performance payloads.
|
||||
- No implemented status transition endpoint for Mark as Completed.
|
||||
|
||||
### 2.3 Key model and constant facts
|
||||
- Job status constants exist in helpers/job_constants.js:
|
||||
- NEW 0, READY 1, DOWNLOADED 2, SPRAYED 3, COMPLETED 4, INVOICED 5, ARCHIVED 9.
|
||||
- Job pilot assignment is by Job.operator (optional ObjectId).
|
||||
- Job ownership is by Job.byPuid (Applicator account).
|
||||
- Aircraft display source is Job.vehicle.
|
||||
- Job_Assign is assignment workflow data and should not be used as pilot-job source of truth for dashboard metrics.
|
||||
- Application links to job by numeric jobId.
|
||||
- Application aggregates needed for dashboard already exist: totalSprayed, totalFlightTime, totalSprLength, totalSprayMat, avgSpraySpeed.
|
||||
- Application_Detail contains xTrack, sprayHeight, radarAlt, gpsAlt and has primary index on fileId.
|
||||
|
||||
## 3. Confirmed Design Direction So Far
|
||||
|
||||
### 3.1 Pilot scoping model
|
||||
The only reliable pilot scope for dashboard metrics is:
|
||||
1. Resolve jobIds from Job where operator = current pilot user id.
|
||||
2. Aggregate Application records where jobId is in that list.
|
||||
3. For quality gauges, resolve recent app files and aggregate Application_Detail by fileId.
|
||||
|
||||
This avoids wrong assumptions around uploader identity and keeps logic aligned with how pilot assignment is represented.
|
||||
|
||||
### 3.2 Dashboard modules (backend perspective)
|
||||
- KPI cards: assigned jobs/ha + sprayed today + flight hours today.
|
||||
- Daily summary: today vs yesterday deltas for hectares, hours, ha/hr, speed, volume.
|
||||
- Operations today: distance and spray volume totals.
|
||||
- Active jobs panel: per-job progress and actual applied volume with status mapping.
|
||||
- Trend: daily hours and hectares over date range (default current week).
|
||||
- Performance indicators: average XT error and altitude, with threshold bands.
|
||||
- Mark Job as Completed: manual SPRAYED to COMPLETED transition.
|
||||
|
||||
## 4. Proposed API Surface (Phase 1)
|
||||
|
||||
Suggested new route group:
|
||||
- /api/dashboard/pilot
|
||||
|
||||
Suggested endpoints:
|
||||
- GET /api/dashboard/pilot/kpi
|
||||
- GET /api/dashboard/pilot/summary
|
||||
- GET /api/dashboard/pilot/trend
|
||||
- GET /api/dashboard/pilot/active-jobs
|
||||
- GET /api/dashboard/pilot/performance
|
||||
|
||||
Job completion action:
|
||||
- PATCH /api/jobs/:jobId/complete
|
||||
|
||||
Implementation notes:
|
||||
- Keep endpoint naming camelCase where needed in path segments to match existing project conventions.
|
||||
- Reuse existing auth middleware and error classes for consistency.
|
||||
|
||||
## 5. Query and Aggregation Approach
|
||||
|
||||
### 5.1 KPI and summary
|
||||
- Base filter: Job.find({ operator: pilotId, markedDelete: { $ne: true } }).
|
||||
- Read assigned hectares from Job.ttSprArea.
|
||||
- Aggregate Applications by jobId with time-window filtering for today/yesterday.
|
||||
- Convert units in backend responses:
|
||||
- hours = seconds / 3600
|
||||
- distance = meters / 1000
|
||||
- speed = m/s to km/h when needed
|
||||
|
||||
### 5.2 Active jobs
|
||||
- Start from pilot-scoped jobs.
|
||||
- Aggregate per job from Application:
|
||||
- haSprayed = sum totalSprayed
|
||||
- volumeApplied = sum totalSprayMat
|
||||
- Compute progressPct = min(100, max(0, haSprayed / haTotal * 100)).
|
||||
- Status display mapping:
|
||||
- NEW (0): no progress bar
|
||||
- READY/DOWNLOADED/SPRAYED: IN PROGRESS behavior
|
||||
- COMPLETED (4): full bar
|
||||
|
||||
### 5.3 Trend
|
||||
- Accept startDate/endDate (max 90 days recommended).
|
||||
- Default to current calendar week (Mon-Sun) in user timezone policy.
|
||||
- Group by day and fill missing dates with zero values.
|
||||
|
||||
### 5.4 Performance
|
||||
- Use recent application files only (for bounded cost).
|
||||
- Fetch recent pilot-scoped Application records, then map to related fileIds.
|
||||
- Query Application_Detail with fileId IN [...].
|
||||
- Compute:
|
||||
- avgXtErrorMeters = avg(abs(xTrack))
|
||||
- altitude with source priority sprayHeight then radarAlt
|
||||
- Return no-data state when sample is empty or sensor fields unavailable.
|
||||
|
||||
## 6. Authorization and Data Isolation
|
||||
|
||||
### 6.1 Pilot dashboard endpoints
|
||||
- Must be pilot-only.
|
||||
- Always derive pilotId from authenticated user context, never trust client-provided user ids.
|
||||
- Never expose other pilots or global Applicator data.
|
||||
|
||||
### 6.2 Mark as Completed endpoint
|
||||
- Allowed transition: SPRAYED (3) to COMPLETED (4) only.
|
||||
- Recommended permission for Phase 1: Applicator owner only (Job.byPuid match).
|
||||
- Return 409 for invalid state transition.
|
||||
- Return 403 when caller is not authorized.
|
||||
|
||||
## 7. Performance and Scalability Considerations
|
||||
|
||||
- Application_Detail is large-scale; avoid broad scans.
|
||||
- Always drive quality queries by fileId-scoped subsets.
|
||||
- Keep performance endpoint sample bounded (for example, last 10 files as proposed).
|
||||
- Ensure lean reads for read-only queries where practical.
|
||||
- Add/validate indexes only where query plans prove necessary after measurement.
|
||||
|
||||
## 8. Key Product Decisions Still Open (Affect Backend)
|
||||
|
||||
- Active Jobs cutoff status set (exact statuses to include).
|
||||
- Assigned Jobs KPI business meaning (all-time vs open vs season).
|
||||
- Altitude fallback policy when sprayHeight/radarAlt is absent.
|
||||
- Whether pilot can trigger completion action or Applicator-only.
|
||||
- Final timezone policy for Today/Yesterday windows (requirements currently point to browser timezone).
|
||||
|
||||
## 9. Recommended Delivery Plan
|
||||
|
||||
### Phase A - Foundation
|
||||
- Add dashboard route/controller/service skeleton.
|
||||
- Add shared pilot scope resolver (pilot to jobIds).
|
||||
- Add consistent response DTO contracts.
|
||||
|
||||
### Phase B - Core endpoints
|
||||
- Implement kpi, summary, trend, active-jobs.
|
||||
- Add input validation for date range/timezone params.
|
||||
- Add empty-state and zero-safe calculations.
|
||||
|
||||
### Phase C - Performance indicators
|
||||
- Implement performance endpoint with bounded recent-file strategy.
|
||||
- Add explicit source label and no-data states.
|
||||
|
||||
### Phase D - Job completion workflow
|
||||
- Implement PATCH complete endpoint with strict state and auth checks.
|
||||
- Add/update JSDoc for API docs.
|
||||
|
||||
### Phase E - Verification
|
||||
- Create tests scripts in tests/ for each endpoint and transition scenarios.
|
||||
- Validate role isolation, response shapes, and edge cases (no jobs, no apps, missing altitude).
|
||||
- Run scripts and record execution output.
|
||||
|
||||
## 10. Risks and Mitigations
|
||||
|
||||
- Risk: Missing Job.operator on some jobs leads to undercount.
|
||||
- Mitigation: surface this as known data-quality dependency in API/docs.
|
||||
|
||||
- Risk: Inconsistent device coverage for altitude metrics.
|
||||
- Mitigation: deterministic source priority + explicit no-data response.
|
||||
|
||||
- Risk: Heavy Application_Detail scans.
|
||||
- Mitigation: strict fileId-scoped querying and bounded sample windows.
|
||||
|
||||
- Risk: Frontend status mismatch for COMPLETED/INVOICED labels.
|
||||
- Mitigation: coordinate backend constants and frontend enum alignment before rollout.
|
||||
|
||||
## 11. Definition of Done for Backend
|
||||
|
||||
- Pilot dashboard endpoints implemented and documented.
|
||||
- Mark Complete transition endpoint implemented with auth and state guards.
|
||||
- APIs return stable contracts for all normal and empty-data scenarios.
|
||||
- Endpoint behavior validated via executed test scripts.
|
||||
- Relevant docs updated in docs/ and JSDoc included for apidoc generation.
|
||||
@ -0,0 +1,396 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "AgMission — Pilot Analytics Dashboard API",
|
||||
"description": "Testing collection for the Pilot Analytics Dashboard endpoints.\n\n## Setup\n1. Set `baseUrl` to your server (e.g. `https://localhost:4100`).\n2. Run **[Auth] Login** — the `jwt` variable is captured automatically.\n3. Set `tz` to your local IANA timezone (e.g. `America/Sao_Paulo`). Defaults to `UTC`.\n4. For the **Complete Job** request, set `jobId` to a job in SPRAYED (3) status owned by the logged-in applicator.\n\n## Folders\n- **[Auth]** — Login to get a JWT\n- **[Dashboard] KPI and Summary** — KPI cards and today/yesterday comparison\n- **[Dashboard] Trend** — Daily trend chart data\n- **[Dashboard] Jobs** — Active jobs panel\n- **[Dashboard] Performance** — XT error and altitude gauges\n- **[Jobs] Complete** — Transition a job from SPRAYED to COMPLETED",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "baseUrl", "value": "https://localhost:4100", "type": "string", "description": "Server base URL" },
|
||||
{ "key": "jwt", "value": "", "type": "string", "description": "Captured automatically by the Login request" },
|
||||
{ "key": "tz", "value": "UTC", "type": "string", "description": "IANA timezone string e.g. America/Sao_Paulo" },
|
||||
{ "key": "jobId", "value": "", "type": "string", "description": "Numeric Job ID in SPRAYED (3) status — used for the Complete Job request" }
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "[Auth]",
|
||||
"item": [
|
||||
{
|
||||
"name": "Login (get JWT)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const r = pm.response.json();",
|
||||
"if (r && r.token) {",
|
||||
" pm.collectionVariables.set('jwt', r.token);",
|
||||
" console.log('JWT captured');",
|
||||
"} else {",
|
||||
" console.warn('Login response did not contain a token:', JSON.stringify(r));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"username\": \"pilot@example.com\",\n \"password\": \"yourpassword\"\n}",
|
||||
"options": { "raw": { "language": "json" } }
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/users/login",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "users", "login"]
|
||||
},
|
||||
"description": "Standard AgMission login. The JWT is stored in the `jwt` collection variable and used automatically by all dashboard requests.\n\nLog in as a **Pilot** user to test dashboard read endpoints. Log in as the **Applicator** (byPuid) to test the Complete Job action."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "[Dashboard] KPI and Summary",
|
||||
"description": "KPI cards and today-vs-yesterday summary panel.",
|
||||
"item": [
|
||||
{
|
||||
"name": "KPI Cards",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Has assignedJobs', () => pm.expect(r).to.have.property('assignedJobs'));",
|
||||
"pm.test('Has assignedHectares', () => pm.expect(r).to.have.property('assignedHectares'));",
|
||||
"pm.test('Has sprayedToday', () => pm.expect(r).to.have.property('sprayedToday'));",
|
||||
"pm.test('Has flightHoursToday', () => pm.expect(r).to.have.property('flightHoursToday'));",
|
||||
"pm.test('Has operations block', () => pm.expect(r.operations).to.have.keys(['distanceKm','sprayVolumeLiters']));",
|
||||
"pm.test('Has historical block', () => pm.expect(r.historical).to.have.keys(['jobs','hectares','flightHours']));"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/kpi?tz={{tz}}",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "kpi"],
|
||||
"query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }]
|
||||
},
|
||||
"description": "Returns all KPI card values for the authenticated pilot.\n\n**Expected fields**: `assignedJobs`, `assignedHectares`, `sprayedToday`, `flightHoursToday`, `operations.distanceKm`, `operations.sprayVolumeLiters`, `historical.jobs/hectares/flightHours` × year/month/week/day.\n\nAll numbers are rounded to 2 decimal places."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Daily Summary (today vs yesterday)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Has today', () => pm.expect(r).to.have.property('today'));",
|
||||
"pm.test('Has yesterday', () => pm.expect(r).to.have.property('yesterday'));",
|
||||
"pm.test('Has deltas', () => pm.expect(r).to.have.property('deltas'));",
|
||||
"pm.test('Today has expected keys', () => pm.expect(r.today).to.have.keys(['hectares','flightHours','haPerHour','avgSpeedKmh','sprayVolumeLiters']));",
|
||||
"pm.test('Deltas Pct values are null or number', () => {",
|
||||
" Object.values(r.deltas).forEach(v => {",
|
||||
" pm.expect(v === null || typeof v === 'number').to.be.true;",
|
||||
" });",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/summary?tz={{tz}}",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "summary"],
|
||||
"query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }]
|
||||
},
|
||||
"description": "Returns today and yesterday operational metrics with percentage change deltas.\n\n`deltas.*Pct` is `null` when yesterday value was 0 (avoids division by zero). Negative = today was worse than yesterday."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "[Dashboard] Trend",
|
||||
"description": "Daily hours and hectares chart data.",
|
||||
"item": [
|
||||
{
|
||||
"name": "Trend — Current Week (default)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Has labels array', () => pm.expect(r.labels).to.be.an('array'));",
|
||||
"pm.test('Has hoursFlown array', () => pm.expect(r.hoursFlown).to.be.an('array'));",
|
||||
"pm.test('Has hectaresPerDay array', () => pm.expect(r.hectaresPerDay).to.be.an('array'));",
|
||||
"pm.test('All arrays same length', () => {",
|
||||
" pm.expect(r.labels.length).to.equal(r.hoursFlown.length);",
|
||||
" pm.expect(r.labels.length).to.equal(r.hectaresPerDay.length);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "trend"],
|
||||
"query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }]
|
||||
},
|
||||
"description": "Returns daily `hoursFlown` and `hectaresPerDay` for the current calendar week (Mon–Sun). All three arrays are the same length and positionally aligned."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Trend — Custom Date Range",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Labels match expected range length', () => {",
|
||||
" pm.expect(r.labels.length).to.equal(14);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}&startDate=2026-04-16&endDate=2026-04-29",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "trend"],
|
||||
"query": [
|
||||
{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" },
|
||||
{ "key": "startDate", "value": "2026-04-16", "description": "YYYY-MM-DD inclusive" },
|
||||
{ "key": "endDate", "value": "2026-04-29", "description": "YYYY-MM-DD inclusive" }
|
||||
]
|
||||
},
|
||||
"description": "14-day range example. Adjust `startDate`/`endDate` as needed. Maximum range is 90 days — requests exceeding that return HTTP 409."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Trend — Over 90 Days (expect 409)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 409 for oversized range', () => pm.response.to.have.status(409));"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}&startDate=2025-01-01&endDate=2026-04-29",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "trend"],
|
||||
"query": [
|
||||
{ "key": "tz", "value": "{{tz}}" },
|
||||
{ "key": "startDate", "value": "2025-01-01" },
|
||||
{ "key": "endDate", "value": "2026-04-29" }
|
||||
]
|
||||
},
|
||||
"description": "Validates the 90-day cap. Expects HTTP 409 `invalid_param`."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "[Dashboard] Jobs",
|
||||
"description": "Active jobs panel.",
|
||||
"item": [
|
||||
{
|
||||
"name": "Active Jobs Panel",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Has jobs array', () => pm.expect(r.jobs).to.be.an('array'));",
|
||||
"if (r.jobs.length > 0) {",
|
||||
" const j = r.jobs[0];",
|
||||
" pm.test('Job has jobId (number)', () => pm.expect(typeof j.jobId).to.equal('number'));",
|
||||
" pm.test('Job has displayStatus', () => pm.expect(['NEW','IN_PROGRESS','COMPLETED']).to.include(j.displayStatus));",
|
||||
" pm.test('Job progressPct in range 0-100', () => pm.expect(j.progressPct).to.be.within(0, 100));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/activeJobs",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "activeJobs"]
|
||||
},
|
||||
"description": "Returns up to 50 jobs in statuses NEW (0) through COMPLETED (4). INVOICED (5) and ARCHIVED (9) are excluded.\n\nEach job has: `jobId` (Number), `name`, `clientName`, `aircraftReg`, `status`, `displayStatus`, `haTotal`, `haSprayed`, `progressPct`, `volumeAppliedLiters`."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "[Dashboard] Performance",
|
||||
"description": "XT error and altitude performance gauges.",
|
||||
"item": [
|
||||
{
|
||||
"name": "Performance Gauges",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Has hasXtData', () => pm.expect(r).to.have.property('hasXtData'));",
|
||||
"pm.test('Has hasAltitudeData', () => pm.expect(r).to.have.property('hasAltitudeData'));",
|
||||
"pm.test('Has xtThreshold', () => pm.expect(r.xtThreshold).to.have.keys(['good','monitor']));",
|
||||
"pm.test('Has altThreshold', () => pm.expect(r.altThreshold).to.have.keys(['target','goodBand','monitorBand']));",
|
||||
"pm.test('Has sampleSize', () => pm.expect(r).to.have.property('sampleSize'));",
|
||||
"if (r.hasXtData) {",
|
||||
" pm.test('avgXtErrorMeters is a number when hasXtData', () => pm.expect(typeof r.avgXtErrorMeters).to.equal('number'));",
|
||||
"}",
|
||||
"if (!r.hasXtData) {",
|
||||
" pm.test('avgXtErrorMeters is null when no XT data', () => pm.expect(r.avgXtErrorMeters).to.be.null);",
|
||||
"}",
|
||||
"if (r.hasAltitudeData) {",
|
||||
" pm.test('altitudeSource is sprayHeight or radarAlt', () => pm.expect(['sprayHeight','radarAlt']).to.include(r.altitudeSource));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/dashboard/pilot/performance",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "dashboard", "pilot", "performance"]
|
||||
},
|
||||
"description": "Returns XT cross-track error and spray altitude averages based on the pilot's last 10 processed application files.\n\n`hasXtData` / `hasAltitudeData` are `false` when no sensor readings exist — render a 'No data' placeholder in that case.\n\n`altitudeSource` is `sprayHeight` (FM sensor, preferred) or `radarAlt` (AGL fallback)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "[Jobs] Complete",
|
||||
"description": "Transition a SPRAYED job to COMPLETED. Must be logged in as the Applicator (byPuid) who owns the job.",
|
||||
"item": [
|
||||
{
|
||||
"name": "Complete Job (happy path)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 200', () => pm.response.to.have.status(200));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Job status is now 4 (COMPLETED)', () => pm.expect(r.status).to.equal(4));"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "jobs", "{{jobId}}", "complete"]
|
||||
},
|
||||
"description": "Transitions job `{{jobId}}` from SPRAYED (3) to COMPLETED (4).\n\nRequirements:\n- Caller must be the Applicator who owns the job (`Job.byPuid === req.uid`).\n- Job must be in SPRAYED (3) status.\n\nSet the `jobId` collection variable to a valid numeric job ID before running."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Complete Job — not owner (expect 401)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 401 when not the job owner', () => pm.response.to.have.status(401));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Error tag is not_authorized', () => pm.expect(r.error['.tag']).to.equal('not_authorized'));"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "jobs", "{{jobId}}", "complete"]
|
||||
},
|
||||
"description": "Run this request while logged in as a **different** user than the job owner to confirm the 401 guard works. Set `jobId` to a job you do NOT own."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Complete Job — wrong status (expect 409)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status is 409 for wrong job status', () => pm.response.to.have.status(409));",
|
||||
"const r = pm.response.json();",
|
||||
"pm.test('Error tag is status_job_invalid', () => pm.expect(r.error['.tag']).to.equal('status_job_invalid'));"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "jobs", "{{jobId}}", "complete"]
|
||||
},
|
||||
"description": "Run this after the happy-path request has already moved the job to COMPLETED (4). The job is no longer in SPRAYED status, so you should receive 409 `status_job_invalid`."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -15,6 +15,7 @@
|
||||
"test:job": "mocha --exit --require tests/setup.js 'tests/job/test_*.js'",
|
||||
"test:payment": "mocha --exit --require tests/setup.js 'tests/payment/test_*.js'",
|
||||
"test:dlq": "mocha --exit --require tests/setup.js 'tests/dlq/test_*.js'",
|
||||
"test:dashboard": "mocha --exit --require tests/setup.js 'tests/test_pilot_dashboard_api.js'",
|
||||
"test:parsing": "mocha --exit --require tests/setup.js 'tests/parsing/test_*.js'",
|
||||
"test:integration": "mocha --exit --require tests/setup.js 'tests/integration/test_*.js'",
|
||||
"test:utils": "mocha --exit --require tests/setup.js 'tests/utils/test_*.js'",
|
||||
@ -119,11 +120,12 @@
|
||||
"email-templates": {
|
||||
"mailparser": "3.6.5"
|
||||
},
|
||||
"concaveman": "1.2.1"
|
||||
"concaveman": "1.2.1",
|
||||
"splaytree": "2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.10",
|
||||
"mocha": "^10.2.0",
|
||||
"chai": "^4.5.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
29
Development/server/routes/dashboard.js
Normal file
29
Development/server/routes/dashboard.js
Normal file
@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = function (app) {
|
||||
const router = require('express').Router();
|
||||
const ctl = require('../controllers/dashboard');
|
||||
|
||||
/**
|
||||
* Pilot analytics dashboard endpoints.
|
||||
* All routes require a valid JWT (enforced by the global checkUser middleware).
|
||||
* Data is always scoped to the authenticated user — pilotId is derived from req.uid.
|
||||
*/
|
||||
|
||||
// KPI summary cards
|
||||
router.get('/pilot/kpi', ctl.getKpi);
|
||||
|
||||
// Daily summary: today vs yesterday with percentage deltas
|
||||
router.get('/pilot/summary', ctl.getSummary);
|
||||
|
||||
// Trend charts: daily hours flown + hectares sprayed over a date range
|
||||
router.get('/pilot/trend', ctl.getTrend);
|
||||
|
||||
// Active jobs panel with per-job progress and applied volume
|
||||
router.get('/pilot/activeJobs', ctl.getActiveJobs);
|
||||
|
||||
// Performance gauges: average XT error and spray altitude
|
||||
router.get('/pilot/performance', ctl.getPerformance);
|
||||
|
||||
app.use('/api/dashboard', router);
|
||||
};
|
||||
@ -30,7 +30,5 @@ module.exports = function (app) {
|
||||
require('./log_payment')(app);
|
||||
require('./partner')(app);
|
||||
require('./health')(app);
|
||||
// Data Export public API (X-API-Key auth) and key management (JWT auth)
|
||||
require('./api_pub')(app);
|
||||
require('./api_keys')(app);
|
||||
};
|
||||
require('./dashboard')(app);
|
||||
};
|
||||
|
||||
@ -63,5 +63,9 @@ module.exports = function (app) {
|
||||
|
||||
router.post('/fetchInvReadyJobs', jobCtl.fetchInvReadyJobs_post);
|
||||
|
||||
// Mark a SPRAYED job as COMPLETED — Applicator-only action
|
||||
const dashCtl = require('../controllers/dashboard');
|
||||
router.patch('/:job_id/complete', dashCtl.completeJob);
|
||||
|
||||
app.use('/api/jobs', checkRqPkgSubscription, router);
|
||||
}
|
||||
|
||||
313
Development/server/tests/test_pilot_dashboard_api.js
Normal file
313
Development/server/tests/test_pilot_dashboard_api.js
Normal file
@ -0,0 +1,313 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pilot Analytics Dashboard API — integration tests
|
||||
*
|
||||
* Run:
|
||||
* npm run test:single tests/test_pilot_dashboard_api.js
|
||||
*
|
||||
* Env vars (set in environment.env or export before running):
|
||||
* DASHBOARD_TEST_TOKEN — JWT for a Pilot user (required for live tests)
|
||||
* PILOT_DASHBOARD_BASE_URL — override server URL (default: http://localhost:4100)
|
||||
* RUN_COMPLETE_TEST=1 — enable the complete-job test
|
||||
* DASHBOARD_TEST_JOB_ID — numeric job ID in SPRAYED(3) status (required when RUN_COMPLETE_TEST=1)
|
||||
*
|
||||
* Environment is loaded by tests/setup.js (via --require in npm test scripts).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const { expect } = require('chai');
|
||||
|
||||
const BASE_URL = process.env.PILOT_DASHBOARD_BASE_URL || process.env.API_BASE_URL || 'https://localhost:4100';
|
||||
const TOKEN = process.env.DASHBOARD_TEST_TOKEN || process.env.AUTH_TOKEN || process.env.TEST_AUTH_TOKEN || '';
|
||||
const RUN_COMPLETE_TEST = process.env.RUN_COMPLETE_TEST === '1';
|
||||
const COMPLETE_JOB_ID = process.env.DASHBOARD_TEST_JOB_ID || '';
|
||||
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
// Shared axios client — attaches JWT and returns { status, data } for every request
|
||||
const client = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
validateStatus: () => true, // never throw on non-2xx; assertions do that
|
||||
httpsAgent,
|
||||
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
|
||||
});
|
||||
|
||||
async function get(path) {
|
||||
try {
|
||||
const res = await client.get(path);
|
||||
return { status: res.status, data: res.data };
|
||||
} catch (err) {
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message === 'socket hang up') {
|
||||
throw new Error(`Server not reachable at ${BASE_URL} — start the server first (${err.code || err.message})`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(path) {
|
||||
try {
|
||||
const res = await client.patch(path);
|
||||
return { status: res.status, data: res.data };
|
||||
} catch (err) {
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message === 'socket hang up') {
|
||||
throw new Error(`Server not reachable at ${BASE_URL} — start the server first (${err.code || err.message})`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureServerAvailable() {
|
||||
try {
|
||||
const res = await client.get('/api/health');
|
||||
return res && typeof res.status === 'number';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pilot Analytics Dashboard API', function () {
|
||||
this.timeout(15000);
|
||||
|
||||
before(async function () {
|
||||
if (!TOKEN) {
|
||||
console.log('\n ⚠ No token — skipping all live API tests.');
|
||||
console.log(' Set DASHBOARD_TEST_TOKEN (or AUTH_TOKEN / TEST_AUTH_TOKEN) to run.\n');
|
||||
return this.skip();
|
||||
}
|
||||
|
||||
const isAvailable = await ensureServerAvailable();
|
||||
if (!isAvailable) {
|
||||
console.log(`\n ⚠ Server not reachable at ${BASE_URL}. Start the server first and rerun this test.\n`);
|
||||
return this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/dashboard/pilot/kpi', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/kpi?tz=UTC'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('has top-level KPI fields', function () {
|
||||
expect(data).to.include.keys('assignedJobs', 'assignedHectares', 'sprayedToday', 'flightHoursToday');
|
||||
});
|
||||
|
||||
it('assignedJobs is a number', function () {
|
||||
expect(data.assignedJobs).to.be.a('number');
|
||||
});
|
||||
|
||||
it('assignedHectares is a number', function () {
|
||||
expect(data.assignedHectares).to.be.a('number');
|
||||
});
|
||||
|
||||
it('has operations block with distanceKm and sprayVolumeLiters', function () {
|
||||
expect(data.operations).to.be.an('object').and.include.keys('distanceKm', 'sprayVolumeLiters');
|
||||
});
|
||||
|
||||
it('has historical block with year/month/week/day sub-objects', function () {
|
||||
expect(data.historical).to.be.an('object').and.include.keys('jobs', 'hectares', 'flightHours');
|
||||
['jobs', 'hectares', 'flightHours'].forEach((k) => {
|
||||
expect(data.historical[k]).to.include.keys('year', 'month', 'week', 'day');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/dashboard/pilot/summary', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/summary?tz=UTC'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('has today, yesterday and deltas', function () {
|
||||
expect(data).to.include.keys('today', 'yesterday', 'deltas');
|
||||
});
|
||||
|
||||
it('today has expected metric keys', function () {
|
||||
expect(data.today).to.include.keys('hectares', 'flightHours', 'haPerHour', 'avgSpeedKmh', 'sprayVolumeLiters');
|
||||
});
|
||||
|
||||
it('delta values are null or a number', function () {
|
||||
Object.values(data.deltas).forEach((v) => {
|
||||
expect(v === null || typeof v === 'number').to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/dashboard/pilot/trend', function () {
|
||||
describe('default (current week)', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/trend?tz=UTC'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('has labels, hoursFlown and hectaresPerDay arrays', function () {
|
||||
expect(data.labels).to.be.an('array');
|
||||
expect(data.hoursFlown).to.be.an('array');
|
||||
expect(data.hectaresPerDay).to.be.an('array');
|
||||
});
|
||||
|
||||
it('all three arrays have equal length', function () {
|
||||
expect(data.hoursFlown).to.have.lengthOf(data.labels.length);
|
||||
expect(data.hectaresPerDay).to.have.lengthOf(data.labels.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom date range (14 days)', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/trend?tz=UTC&startDate=2026-04-16&endDate=2026-04-29'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('returns exactly 14 data points', function () {
|
||||
expect(data.labels).to.have.lengthOf(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('range exceeding 90-day cap', function () {
|
||||
let status;
|
||||
|
||||
before(async function () {
|
||||
({ status } = await get('/api/dashboard/pilot/trend?tz=UTC&startDate=2025-01-01&endDate=2026-04-29'));
|
||||
});
|
||||
|
||||
it('returns HTTP 409', function () {
|
||||
expect(status).to.equal(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/dashboard/pilot/activeJobs', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/activeJobs'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('has a jobs array', function () {
|
||||
expect(data.jobs).to.be.an('array');
|
||||
});
|
||||
|
||||
it('each job has required fields', function () {
|
||||
data.jobs.forEach((j) => {
|
||||
expect(j).to.include.keys('jobId', 'name', 'status', 'displayStatus', 'progressPct');
|
||||
expect(j.jobId).to.be.a('number');
|
||||
expect(['NEW', 'IN_PROGRESS', 'COMPLETED']).to.include(j.displayStatus);
|
||||
expect(j.progressPct).to.be.within(0, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/dashboard/pilot/performance', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
({ status, data } = await get('/api/dashboard/pilot/performance'));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('has presence flags and threshold configs', function () {
|
||||
expect(data).to.include.keys('hasXtData', 'hasAltitudeData', 'sampleSize', 'xtThreshold', 'altThreshold');
|
||||
});
|
||||
|
||||
it('hasXtData and hasAltitudeData are booleans', function () {
|
||||
expect(data.hasXtData).to.be.a('boolean');
|
||||
expect(data.hasAltitudeData).to.be.a('boolean');
|
||||
});
|
||||
|
||||
it('avgXtErrorMeters is null when no XT data, number otherwise', function () {
|
||||
if (data.hasXtData) {
|
||||
expect(data.avgXtErrorMeters).to.be.a('number');
|
||||
} else {
|
||||
expect(data.avgXtErrorMeters).to.be.null;
|
||||
}
|
||||
});
|
||||
|
||||
it('altitudeSource is sprayHeight or radarAlt when altitude data exists', function () {
|
||||
if (data.hasAltitudeData) {
|
||||
expect(['sprayHeight', 'radarAlt']).to.include(data.altitudeSource);
|
||||
}
|
||||
});
|
||||
|
||||
it('xtThreshold has good and monitor keys', function () {
|
||||
expect(data.xtThreshold).to.include.keys('good', 'monitor');
|
||||
});
|
||||
|
||||
it('altThreshold has target, goodBand and monitorBand keys', function () {
|
||||
expect(data.altThreshold).to.include.keys('target', 'goodBand', 'monitorBand');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('PATCH /api/jobs/:job_id/complete', function () {
|
||||
before(function () {
|
||||
if (!RUN_COMPLETE_TEST) return this.skip();
|
||||
if (!COMPLETE_JOB_ID) return this.skip();
|
||||
});
|
||||
|
||||
describe('happy path', function () {
|
||||
let status, data;
|
||||
|
||||
before(async function () {
|
||||
if (!RUN_COMPLETE_TEST || !COMPLETE_JOB_ID) return this.skip();
|
||||
({ status, data } = await patch(`/api/jobs/${COMPLETE_JOB_ID}/complete`));
|
||||
});
|
||||
|
||||
it('returns HTTP 200', function () {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
|
||||
it('job status is now COMPLETED (4)', function () {
|
||||
expect(data.status).to.equal(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-completing an already-completed job', function () {
|
||||
let status;
|
||||
|
||||
before(async function () {
|
||||
if (!RUN_COMPLETE_TEST || !COMPLETE_JOB_ID) return this.skip();
|
||||
({ status } = await patch(`/api/jobs/${COMPLETE_JOB_ID}/complete`));
|
||||
});
|
||||
|
||||
it('returns HTTP 409 (invalid status transition)', function () {
|
||||
expect(status).to.equal(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user