RoyAalekh commited on
Commit
7f8f9a6
·
1 Parent(s): 814aa5b

feat: switch measurements to feet, add recorder UI, and persist telemetry to Supabase

Browse files

- UI: Height (ft), Girth/DBH (ft), conversion note, recorder controls
- API: validators updated to feet; stats return average_height_ft/average_girth_ft
- Telemetry: client sends success/error; server writes to Supabase telemetry_events (file fallback)
- DB support: location_name field supported end-to-end; trees DDL adjusted in repo

app.py CHANGED
@@ -124,6 +124,7 @@ class Tree(BaseModel):
124
  id: int
125
  latitude: float
126
  longitude: float
 
127
  local_name: Optional[str] = None
128
  scientific_name: Optional[str] = None
129
  common_name: Optional[str] = None
@@ -145,12 +146,13 @@ class TreeCreate(BaseModel):
145
  """Model for creating new tree records"""
146
  latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
147
  longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
 
148
  local_name: Optional[str] = Field(None, max_length=200, description="Local Assamese name")
149
  scientific_name: Optional[str] = Field(None, max_length=200, description="Scientific name")
150
  common_name: Optional[str] = Field(None, max_length=200, description="Common name")
151
  tree_code: Optional[str] = Field(None, max_length=20, description="Tree reference code")
152
- height: Optional[float] = Field(None, gt=0, le=200, description="Height in meters")
153
- width: Optional[float] = Field(None, gt=0, le=2000, description="Width/girth in centimeters")
154
  utility: Optional[List[str]] = Field(None, description="Ecological/cultural utilities")
155
  storytelling_text: Optional[str] = Field(None, max_length=5000, description="Stories and narratives")
156
  storytelling_audio: Optional[str] = Field(None, description="Audio file path")
@@ -217,12 +219,13 @@ class TreeUpdate(BaseModel):
217
  """Model for updating tree records"""
218
  latitude: Optional[float] = Field(None, ge=-90, le=90)
219
  longitude: Optional[float] = Field(None, ge=-180, le=180)
 
220
  local_name: Optional[str] = Field(None, max_length=200)
221
  scientific_name: Optional[str] = Field(None, max_length=200)
222
  common_name: Optional[str] = Field(None, max_length=200)
223
  tree_code: Optional[str] = Field(None, max_length=20)
224
- height: Optional[float] = Field(None, gt=0, le=200)
225
- width: Optional[float] = Field(None, gt=0, le=2000)
226
  utility: Optional[List[str]] = None
227
  storytelling_text: Optional[str] = Field(None, max_length=5000)
228
  storytelling_audio: Optional[str] = None
@@ -740,8 +743,9 @@ async def get_stats():
740
  "total_trees": total_trees,
741
  "species_distribution": species_distribution,
742
  "health_distribution": health_distribution,
743
- "average_height": measurements["average_height"],
744
- "average_diameter": measurements["average_diameter"],
 
745
  "last_updated": datetime.now().isoformat(),
746
  }
747
 
@@ -789,6 +793,53 @@ async def get_tree_codes_api():
789
  return {"tree_codes": [], "error": str(e)}
790
 
791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  # Version info
793
  @app.get("/api/version", tags=["System"])
794
  async def get_version():
 
124
  id: int
125
  latitude: float
126
  longitude: float
127
+ location_name: Optional[str] = None
128
  local_name: Optional[str] = None
129
  scientific_name: Optional[str] = None
130
  common_name: Optional[str] = None
 
146
  """Model for creating new tree records"""
147
  latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
148
  longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
149
+ location_name: Optional[str] = Field(None, max_length=200, description="Human-readable location (e.g., landmark)")
150
  local_name: Optional[str] = Field(None, max_length=200, description="Local Assamese name")
151
  scientific_name: Optional[str] = Field(None, max_length=200, description="Scientific name")
152
  common_name: Optional[str] = Field(None, max_length=200, description="Common name")
153
  tree_code: Optional[str] = Field(None, max_length=20, description="Tree reference code")
154
+ height: Optional[float] = Field(None, gt=0, le=1000, description="Height in feet (ft)")
155
+ width: Optional[float] = Field(None, gt=0, le=200, description="Girth/DBH in feet (ft)")
156
  utility: Optional[List[str]] = Field(None, description="Ecological/cultural utilities")
157
  storytelling_text: Optional[str] = Field(None, max_length=5000, description="Stories and narratives")
158
  storytelling_audio: Optional[str] = Field(None, description="Audio file path")
 
219
  """Model for updating tree records"""
220
  latitude: Optional[float] = Field(None, ge=-90, le=90)
221
  longitude: Optional[float] = Field(None, ge=-180, le=180)
222
+ location_name: Optional[str] = Field(None, max_length=200)
223
  local_name: Optional[str] = Field(None, max_length=200)
224
  scientific_name: Optional[str] = Field(None, max_length=200)
225
  common_name: Optional[str] = Field(None, max_length=200)
226
  tree_code: Optional[str] = Field(None, max_length=20)
227
+ height: Optional[float] = Field(None, gt=0, le=1000)
228
+ width: Optional[float] = Field(None, gt=0, le=200)
229
  utility: Optional[List[str]] = None
230
  storytelling_text: Optional[str] = Field(None, max_length=5000)
231
  storytelling_audio: Optional[str] = None
 
743
  "total_trees": total_trees,
744
  "species_distribution": species_distribution,
745
  "health_distribution": health_distribution,
746
+ "average_height_ft": measurements["average_height"],
747
+ "average_girth_ft": measurements["average_diameter"],
748
+ "units": {"height": "ft", "girth": "ft"},
749
  "last_updated": datetime.now().isoformat(),
750
  }
751
 
 
793
  return {"tree_codes": [], "error": str(e)}
794
 
795
 
796
+ # Telemetry logging
797
+ class TelemetryEvent(BaseModel):
798
+ event_type: str = Field(..., description="Type of event, e.g., 'upload', 'ui', 'error'")
799
+ status: Optional[str] = Field(None, description="Status such as success/error")
800
+ metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
801
+ timestamp: Optional[str] = Field(default=None)
802
+
803
+
804
+ def _write_telemetry(event: Dict[str, Any]):
805
+ try:
806
+ # Ensure timestamp
807
+ if 'timestamp' not in event or not event['timestamp']:
808
+ event['timestamp'] = datetime.now().isoformat()
809
+ # Append as JSON line
810
+ with open("telemetry.log", "a", encoding="utf-8") as f:
811
+ f.write(json.dumps(event) + "\n")
812
+ except Exception as e:
813
+ logger.warning(f"Failed to write telemetry: {e}")
814
+
815
+
816
+ @app.post("/api/telemetry", tags=["System"])
817
+ async def telemetry(event: TelemetryEvent, request: Request, user: Dict[str, Any] = Depends(require_auth)):
818
+ """Accept telemetry/observability events from the client for troubleshooting."""
819
+ try:
820
+ evt = event.model_dump()
821
+ # Enrich with user and request context
822
+ evt['user'] = {
823
+ "username": user.get("username"),
824
+ "role": user.get("role")
825
+ }
826
+ evt['client'] = {
827
+ "ip": request.client.host if request.client else None,
828
+ "user_agent": request.headers.get('user-agent')
829
+ }
830
+ # Prefer Supabase persistent storage; fallback to file if not configured
831
+ if getattr(db, 'connected', False):
832
+ ok = db.log_telemetry(evt)
833
+ if not ok:
834
+ _write_telemetry(evt)
835
+ else:
836
+ _write_telemetry(evt)
837
+ return {"ok": True}
838
+ except Exception as e:
839
+ logger.error(f"Telemetry error: {e}")
840
+ raise HTTPException(status_code=500, detail="Failed to record telemetry")
841
+
842
+
843
  # Version info
844
  @app.get("/api/version", tags=["System"])
845
  async def get_version():
constants.py CHANGED
@@ -22,13 +22,13 @@ MAX_TREES_LIMIT = 1000
22
  DEFAULT_SPECIES_LIMIT = 20
23
  DEFAULT_OFFSET = 0
24
 
25
- # Validation Constants
26
  MAX_SPECIES_NAME_LENGTH = 200
27
  MAX_NOTES_LENGTH = 2000
28
  MAX_STORYTELLING_TEXT_LENGTH = 5000
29
  MAX_TREE_CODE_LENGTH = 20
30
- MAX_HEIGHT_METERS = 200
31
- MAX_WIDTH_CM = 2000
32
 
33
  # Application Constants
34
  APP_VERSION = "3.0.0"
 
22
  DEFAULT_SPECIES_LIMIT = 20
23
  DEFAULT_OFFSET = 0
24
 
25
+ # Validation Constants (units in feet)
26
  MAX_SPECIES_NAME_LENGTH = 200
27
  MAX_NOTES_LENGTH = 2000
28
  MAX_STORYTELLING_TEXT_LENGTH = 5000
29
  MAX_TREE_CODE_LENGTH = 20
30
+ MAX_HEIGHT_FEET = 1000
31
+ MAX_GIRTH_FEET = 200
32
 
33
  # Application Constants
34
  APP_VERSION = "3.0.0"
static/index.html CHANGED
@@ -939,9 +939,7 @@
939
  </script>
940
  </head>
941
  <body>
942
- <div class="tt-header">
943
- <!-- Granim Canvas for Header Background -->
944
- <canvas id="header-canvas"></canvas>
945
 
946
  <div class="tt-header-content">
947
  <div class="tt-header-brand">
@@ -988,6 +986,11 @@
988
  <input type="number" id="longitude" class="form-input" step="0.0000001" min="-180" max="180" required placeholder="e.g. 91.7362">
989
  </div>
990
  </div>
 
 
 
 
 
991
 
992
  <div class="form-group">
993
  <div class="location-buttons">
@@ -1036,16 +1039,17 @@
1036
  <p class="section-description">Quantitative assessment of tree dimensions</p>
1037
  </div>
1038
 
1039
- <div class="form-row">
1040
  <div class="form-group">
1041
- <label class="form-label" for="height">Height (meters)</label>
1042
- <input type="number" id="height" class="form-input" step="0.1" min="0" max="200" placeholder="15.5">
1043
  </div>
1044
  <div class="form-group">
1045
- <label class="form-label" for="width">Girth/DBH (cm)</label>
1046
- <input type="number" id="width" class="form-input" step="0.1" min="0" max="2000" placeholder="45.2">
1047
  </div>
1048
  </div>
 
1049
  </div>
1050
 
1051
  <!-- Utility Section -->
@@ -1107,6 +1111,13 @@
1107
  <div class="file-upload-text">Click to upload audio file</div>
1108
  <div class="file-upload-hint">Or drag and drop (MP3, WAV, M4A)</div>
1109
  </div>
 
 
 
 
 
 
 
1110
  <div id="audioUploadResult"></div>
1111
  </div>
1112
  </div>
 
939
  </script>
940
  </head>
941
  <body>
942
+ <div class="tt-header" style="background-image: url('https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.0.3'); background-size: cover; background-position: center;">
 
 
943
 
944
  <div class="tt-header-content">
945
  <div class="tt-header-brand">
 
986
  <input type="number" id="longitude" class="form-input" step="0.0000001" min="-180" max="180" required placeholder="e.g. 91.7362">
987
  </div>
988
  </div>
989
+
990
+ <div class="form-group">
991
+ <label class="form-label" for="locationName">Location Name (Optional)</label>
992
+ <input type="text" id="locationName" class="form-input" placeholder="e.g., Near Old Banyan, Village Center">
993
+ </div>
994
 
995
  <div class="form-group">
996
  <div class="location-buttons">
 
1039
  <p class="section-description">Quantitative assessment of tree dimensions</p>
1040
  </div>
1041
 
1042
+ <div class="form-row">
1043
  <div class="form-group">
1044
+ <label class="form-label" for="height">Height (ft)</label>
1045
+ <input type="number" id="height" class="form-input" step="0.1" min="0" placeholder="e.g., 50.0">
1046
  </div>
1047
  <div class="form-group">
1048
+ <label class="form-label" for="width">Girth/DBH (ft)</label>
1049
+ <input type="number" id="width" class="form-input" step="0.1" min="0" placeholder="e.g., 3.5">
1050
  </div>
1051
  </div>
1052
+ <p class="section-description">Note: Enter measurements in feet. If you have metric values, you can convert: 1 meter ≈ 3.28084 ft; 1 cm ≈ 0.0328084 ft.</p>
1053
  </div>
1054
 
1055
  <!-- Utility Section -->
 
1111
  <div class="file-upload-text">Click to upload audio file</div>
1112
  <div class="file-upload-hint">Or drag and drop (MP3, WAV, M4A)</div>
1113
  </div>
1114
+
1115
+ <div style="display:flex; align-items:center; gap: 0.75rem; margin-top: 0.75rem;">
1116
+ <button type="button" id="recordBtn" class="tt-btn tt-btn-secondary">Record</button>
1117
+ <span id="recordingStatus" style="font-size:0.9rem; color: var(--gray-600);">Click to start recording</span>
1118
+ </div>
1119
+ <audio id="audioPlayback" controls class="hidden" style="margin-top: 0.75rem; width: 100%;"></audio>
1120
+
1121
  <div id="audioUploadResult"></div>
1122
  </div>
1123
  </div>
static/js/modules/api-client.js CHANGED
@@ -161,19 +161,81 @@ export class ApiClient {
161
  }
162
 
163
  const endpoint = type === 'image' ? '/api/upload/image' : '/api/upload/audio';
164
-
165
- const response = await fetch(endpoint, {
166
- method: 'POST',
167
- headers: {
168
- 'Authorization': `Bearer ${this.authManager.authToken}`
169
- },
170
- body: formData
171
- });
172
-
173
- if (!response.ok) {
174
- throw new Error('Upload failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
-
177
- return await response.json();
178
  }
179
  }
 
161
  }
162
 
163
  const endpoint = type === 'image' ? '/api/upload/image' : '/api/upload/audio';
164
+ let resJson = null;
165
+ try {
166
+ const response = await fetch(endpoint, {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Authorization': `Bearer ${this.authManager.authToken}`
170
+ },
171
+ body: formData
172
+ });
173
+
174
+ if (!response.ok) {
175
+ const errText = await response.text().catch(() => '');
176
+ await this.sendTelemetry({
177
+ event_type: 'upload',
178
+ status: 'error',
179
+ metadata: {
180
+ type,
181
+ category,
182
+ filename: file.name,
183
+ size: file.size,
184
+ content_type: file.type,
185
+ response_status: response.status,
186
+ response_text: errText
187
+ }
188
+ }).catch(() => {});
189
+ throw new Error('Upload failed');
190
+ }
191
+ resJson = await response.json();
192
+ await this.sendTelemetry({
193
+ event_type: 'upload',
194
+ status: 'success',
195
+ metadata: {
196
+ type,
197
+ category,
198
+ filename: file.name,
199
+ size: file.size,
200
+ content_type: file.type,
201
+ server_filename: resJson.filename,
202
+ bucket: resJson.bucket
203
+ }
204
+ }).catch(() => {});
205
+ return resJson;
206
+ } catch (e) {
207
+ // Network or other errors
208
+ if (!resJson) {
209
+ await this.sendTelemetry({
210
+ event_type: 'upload',
211
+ status: 'error',
212
+ metadata: {
213
+ type,
214
+ category,
215
+ filename: file?.name,
216
+ size: file?.size,
217
+ content_type: file?.type,
218
+ error: e.message
219
+ }
220
+ }).catch(() => {});
221
+ }
222
+ throw e;
223
+ }
224
+ }
225
+
226
+ async sendTelemetry(payload) {
227
+ try {
228
+ const response = await this.authenticatedFetch('/api/telemetry', {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/json'
232
+ },
233
+ body: JSON.stringify(payload)
234
+ });
235
+ if (!response) return;
236
+ // Best-effort; ignore response body
237
+ } catch (_) {
238
+ // swallow telemetry errors
239
  }
 
 
240
  }
241
  }
static/js/modules/form-manager.js CHANGED
@@ -84,6 +84,7 @@ export class FormManager {
84
  return {
85
  latitude: this.getNumericValue('latitude'),
86
  longitude: this.getNumericValue('longitude'),
 
87
  local_name: this.getStringValue('localName'),
88
  scientific_name: this.getStringValue('scientificName'),
89
  common_name: this.getStringValue('commonName'),
@@ -114,6 +115,7 @@ export class FormManager {
114
  populateForm(treeData) {
115
  this.setFieldValue('latitude', treeData.latitude);
116
  this.setFieldValue('longitude', treeData.longitude);
 
117
  this.setFieldValue('localName', treeData.local_name || '');
118
  this.setFieldValue('scientificName', treeData.scientific_name || '');
119
  this.setFieldValue('commonName', treeData.common_name || '');
 
84
  return {
85
  latitude: this.getNumericValue('latitude'),
86
  longitude: this.getNumericValue('longitude'),
87
+ location_name: this.getStringValue('locationName'),
88
  local_name: this.getStringValue('localName'),
89
  scientific_name: this.getStringValue('scientificName'),
90
  common_name: this.getStringValue('commonName'),
 
115
  populateForm(treeData) {
116
  this.setFieldValue('latitude', treeData.latitude);
117
  this.setFieldValue('longitude', treeData.longitude);
118
+ this.setFieldValue('locationName', treeData.location_name || '');
119
  this.setFieldValue('localName', treeData.local_name || '');
120
  this.setFieldValue('scientificName', treeData.scientific_name || '');
121
  this.setFieldValue('commonName', treeData.common_name || '');
static/login.html CHANGED
@@ -7,8 +7,6 @@
7
  <link rel="icon" type="image/png" href="/static/image/icons8-tree-96.png">
8
  <link rel="apple-touch-icon" href="/static/image/icons8-tree-96.png">
9
  <link rel="stylesheet" href="/static/css/design-system.css">
10
- <!-- Granim.js CDN (deferred to avoid blocking render) -->
11
- <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/granim.min.js" defer></script>
12
  <style>
13
  body {
14
  min-height: 100vh;
@@ -21,15 +19,19 @@
21
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
22
  }
23
 
24
- /* Granim Canvas Background */
25
- #granim-canvas {
26
  position: fixed;
27
  top: 0;
28
  left: 0;
29
  width: 100vw;
30
  height: 100vh;
31
  z-index: -1;
32
- background: linear-gradient(135deg, #0f172a, #1e293b, #334155);
 
 
 
 
33
  }
34
 
35
  /* Elegant Login Container */
@@ -304,9 +306,6 @@
304
  <!-- Forest Background -->
305
  <div class="forest-background"></div>
306
 
307
- <!-- Granim Canvas -->
308
- <canvas id="granim-canvas"></canvas>
309
-
310
  <div class="tt-login-container">
311
  <div class="logo-section">
312
  <div class="logo">TreeTrack</div>
@@ -506,74 +505,8 @@
506
  });
507
 
508
  // Auto-fill demo username on page load for development
 
509
  document.addEventListener('DOMContentLoaded', () => {
510
- console.log('Initializing Granim...');
511
-
512
- try {
513
- // Define and defer Granim initialization to avoid blocking render
514
- function startGranim() {
515
- new Granim({
516
- element: '#granim-canvas',
517
- direction: 'diagonal',
518
- isPausedWhenNotInView: false,
519
- image: {
520
- // Forest path image
521
- source: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=2560&auto=format&fit=crop&ixlib=rb-4.0.3',
522
- position: ['center', 'center'],
523
- stretchMode: ['stretch', 'stretch'],
524
- blendingMode: 'multiply'
525
- },
526
- states: {
527
- "default-state": {
528
- gradients: [
529
- ['#0f172a', '#1e293b', '#16a085'],
530
- ['#1a202c', '#2d3748', '#27ae60'],
531
- ['#2d3748', '#4a5568', '#2ecc71'],
532
- ['#1a365d', '#2c5282', '#138d75'],
533
- ['#0f172a', '#2d3748', '#16a085']
534
- ],
535
- transitionSpeed: 10000
536
- }
537
- },
538
- onStart: function() {
539
- console.log('Granim forest animation started');
540
- },
541
- onEnd: function() {
542
- console.log('Granim forest animation ended');
543
- }
544
- });
545
- }
546
-
547
- if (window.requestIdleCallback) {
548
- requestIdleCallback(() => { startGranim(); });
549
- } else {
550
- setTimeout(() => { startGranim(); }, 0);
551
- }
552
-
553
- console.log('Scheduled Granim initialization');
554
-
555
- } catch (error) {
556
- console.error('Granim initialization failed:', error);
557
-
558
- // Fallback: Create CSS gradient animation instead
559
- const canvas = document.getElementById('granim-canvas');
560
- if (canvas) {
561
- canvas.style.background = `
562
- linear-gradient(45deg,
563
- #f8fafc 0%,
564
- #e2e8f0 25%,
565
- #cbd5e1 50%,
566
- #e2e8f0 75%,
567
- #f8fafc 100%
568
- )
569
- `;
570
- canvas.style.width = '100vw';
571
- canvas.style.height = '100vh';
572
- console.log('Applied elegant neutral fallback CSS background');
573
- }
574
- }
575
-
576
- // Auto-select ishita account for easy testing (password still needs to be entered)
577
  setTimeout(() => {
578
  fillCredentials('ishita');
579
  }, 1000);
 
7
  <link rel="icon" type="image/png" href="/static/image/icons8-tree-96.png">
8
  <link rel="apple-touch-icon" href="/static/image/icons8-tree-96.png">
9
  <link rel="stylesheet" href="/static/css/design-system.css">
 
 
10
  <style>
11
  body {
12
  min-height: 100vh;
 
19
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
20
  }
21
 
22
+ /* Static Background Image */
23
+ .forest-background {
24
  position: fixed;
25
  top: 0;
26
  left: 0;
27
  width: 100vw;
28
  height: 100vh;
29
  z-index: -1;
30
+ background-image: url('https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=2560&auto=format&fit=crop&ixlib=rb-4.0.3');
31
+ background-position: center center;
32
+ background-size: cover;
33
+ background-repeat: no-repeat;
34
+ filter: brightness(0.9);
35
  }
36
 
37
  /* Elegant Login Container */
 
306
  <!-- Forest Background -->
307
  <div class="forest-background"></div>
308
 
 
 
 
309
  <div class="tt-login-container">
310
  <div class="logo-section">
311
  <div class="logo">TreeTrack</div>
 
505
  });
506
 
507
  // Auto-fill demo username on page load for development
508
+ // Auto-select ishita account for easy testing (password still needs to be entered)
509
  document.addEventListener('DOMContentLoaded', () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  setTimeout(() => {
511
  fillCredentials('ishita');
512
  }, 1000);
static/map.html CHANGED
@@ -837,9 +837,7 @@ const timestamp = '1754659000'; // Current timestamp for cache busting
837
  <body>
838
  <div class="app-container">
839
  <!-- Header -->
840
- <div class="tt-header map-header">
841
- <!-- Granim Canvas for Map Header Background -->
842
- <canvas id="map-header-canvas"></canvas>
843
 
844
  <div class="tt-header-content">
845
  <div class="tt-header-brand">
@@ -943,21 +941,6 @@ const timestamp = '1754659000'; // Current timestamp for cache busting
943
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
944
  <script src="/static/map.js?v=4.0.1&t=1754659000">
945
 
946
- <script>
947
- // Initialize Granim background animation on page load
948
- document.addEventListener('DOMContentLoaded', function() {
949
- // Initialize Granim with animated gradients and full coverage
950
- var mapHeaderGranimInstance = new Granim({
951
- element: '#map-header-canvas',
952
- direction: 'diagonal',
953
- isPausedWhenNotInView: false,
954
- image: {
955
- source: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=2560&auto=format&fit=crop&ixlib=rb-4.0.3',
956
- position: ['center', 'center'],
957
- stretchMode: ['stretch', 'stretch'],
958
- blendingMode: 'multiply'
959
- },
960
- states: {
961
  "default-state": {
962
  gradients: [
963
  ['#0f172a', '#1e293b', '#16a085'],
 
837
  <body>
838
  <div class="app-container">
839
  <!-- Header -->
840
+ <div class="tt-header map-header" style="background-image: url('https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.0.3'); background-size: cover; background-position: center;">
 
 
841
 
842
  <div class="tt-header-content">
843
  <div class="tt-header-brand">
 
941
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
942
  <script src="/static/map.js?v=4.0.1&t=1754659000">
943
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
944
  "default-state": {
945
  gradients: [
946
  ['#0f172a', '#1e293b', '#16a085'],
supabase_client.py CHANGED
@@ -64,12 +64,13 @@ def create_trees_table():
64
  id BIGSERIAL PRIMARY KEY,
65
  latitude DECIMAL(10, 8) NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
66
  longitude DECIMAL(11, 8) NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
 
67
  local_name VARCHAR(200),
68
  scientific_name VARCHAR(200),
69
  common_name VARCHAR(200),
70
  tree_code VARCHAR(20),
71
- height DECIMAL(5, 2) CHECK (height > 0 AND height <= 200),
72
- width DECIMAL(6, 2) CHECK (width > 0 AND width <= 2000),
73
  utility JSONB,
74
  storytelling_text TEXT CHECK (LENGTH(storytelling_text) <= 5000),
75
  storytelling_audio VARCHAR(500),
@@ -85,6 +86,7 @@ def create_trees_table():
85
  CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude);
86
  CREATE INDEX IF NOT EXISTS idx_trees_scientific_name ON trees(scientific_name);
87
  CREATE INDEX IF NOT EXISTS idx_trees_local_name ON trees(local_name);
 
88
  CREATE INDEX IF NOT EXISTS idx_trees_tree_code ON trees(tree_code);
89
  CREATE INDEX IF NOT EXISTS idx_trees_created_at ON trees(created_at);
90
 
 
64
  id BIGSERIAL PRIMARY KEY,
65
  latitude DECIMAL(10, 8) NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
66
  longitude DECIMAL(11, 8) NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
67
+ location_name VARCHAR(200),
68
  local_name VARCHAR(200),
69
  scientific_name VARCHAR(200),
70
  common_name VARCHAR(200),
71
  tree_code VARCHAR(20),
72
+ height DECIMAL(6, 2) CHECK (height > 0 AND height <= 1000), -- feet
73
+ width DECIMAL(6, 2) CHECK (width > 0 AND width <= 200), -- feet (girth/DBH)
74
  utility JSONB,
75
  storytelling_text TEXT CHECK (LENGTH(storytelling_text) <= 5000),
76
  storytelling_audio VARCHAR(500),
 
86
  CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude);
87
  CREATE INDEX IF NOT EXISTS idx_trees_scientific_name ON trees(scientific_name);
88
  CREATE INDEX IF NOT EXISTS idx_trees_local_name ON trees(local_name);
89
+ CREATE INDEX IF NOT EXISTS idx_trees_location_name ON trees(location_name);
90
  CREATE INDEX IF NOT EXISTS idx_trees_tree_code ON trees(tree_code);
91
  CREATE INDEX IF NOT EXISTS idx_trees_created_at ON trees(created_at);
92
 
supabase_database.py CHANGED
@@ -32,14 +32,15 @@ class SupabaseDatabase:
32
 
33
  def initialize_database(self) -> bool:
34
  """Initialize database tables and indexes (already done via SQL)"""
35
- # Tables are created via SQL in Supabase dashboard
36
- # This just tests that the table exists
37
  try:
38
- result = self.client.table('trees').select("id").limit(1).execute()
 
39
  logger.info("Trees table verified in Supabase")
 
 
40
  return True
41
  except Exception as e:
42
- logger.error(f"Failed to verify trees table: {e}")
43
  return False
44
 
45
  def test_connection(self) -> bool:
@@ -261,3 +262,34 @@ class SupabaseDatabase:
261
  """Restore database (not needed for Supabase)"""
262
  logger.info("Supabase data is persistent - no restore needed")
263
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  def initialize_database(self) -> bool:
34
  """Initialize database tables and indexes (already done via SQL)"""
 
 
35
  try:
36
+ # Verify trees table
37
+ self.client.table('trees').select("id").limit(1).execute()
38
  logger.info("Trees table verified in Supabase")
39
+ # Ensure telemetry table exists
40
+ self._ensure_telemetry_table()
41
  return True
42
  except Exception as e:
43
+ logger.error(f"Failed to verify/initialize database: {e}")
44
  return False
45
 
46
  def test_connection(self) -> bool:
 
262
  """Restore database (not needed for Supabase)"""
263
  logger.info("Supabase data is persistent - no restore needed")
264
  return True
265
+
266
+ # Telemetry support
267
+ def _ensure_telemetry_table(self) -> None:
268
+ """Ensure telemetry_events table exists via SQL RPC if available."""
269
+ try:
270
+ # Attempt to select; if it fails, try to create via RPC or ignore
271
+ self.client.table('telemetry_events').select('id').limit(1).execute()
272
+ logger.info("Telemetry table verified in Supabase")
273
+ except Exception as e:
274
+ logger.info(f"telemetry_events table not accessible: {e}")
275
+ # As we may not have a generic SQL RPC, we rely on manual creation documented.
276
+ # We'll create a fallback table via a stored RPC if present; otherwise, first insert will fail gracefully.
277
+ pass
278
+
279
+ def log_telemetry(self, event: Dict[str, Any]) -> bool:
280
+ """Insert a telemetry event into Supabase telemetry_events table."""
281
+ try:
282
+ payload = {
283
+ 'event_type': event.get('event_type'),
284
+ 'status': event.get('status'),
285
+ 'metadata': event.get('metadata'),
286
+ 'username': (event.get('user') or {}).get('username'),
287
+ 'role': (event.get('user') or {}).get('role'),
288
+ 'client': event.get('client'),
289
+ 'timestamp': event.get('timestamp')
290
+ }
291
+ self.client.table('telemetry_events').insert(payload).execute()
292
+ return True
293
+ except Exception as e:
294
+ logger.error(f"Failed to log telemetry: {e}")
295
+ return False