Spaces:
Sleeping
Sleeping
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 +57 -6
- constants.py +3 -3
- static/index.html +19 -8
- static/js/modules/api-client.js +75 -13
- static/js/modules/form-manager.js +2 -0
- static/login.html +8 -75
- static/map.html +1 -18
- supabase_client.py +4 -2
- supabase_database.py +36 -4
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=
|
153 |
-
width: Optional[float] = Field(None, gt=0, le=
|
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=
|
225 |
-
width: Optional[float] = Field(None, gt=0, le=
|
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 |
-
"
|
744 |
-
"
|
|
|
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 |
-
|
31 |
-
|
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 |
-
|
1040 |
<div class="form-group">
|
1041 |
-
<label class="form-label" for="height">Height (
|
1042 |
-
<input type="number" id="height" class="form-input" step="0.1" min="0"
|
1043 |
</div>
|
1044 |
<div class="form-group">
|
1045 |
-
<label class="form-label" for="width">Girth/DBH (
|
1046 |
-
<input type="number" id="width" class="form-input" step="0.1" min="0"
|
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 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
/*
|
25 |
-
|
26 |
position: fixed;
|
27 |
top: 0;
|
28 |
left: 0;
|
29 |
width: 100vw;
|
30 |
height: 100vh;
|
31 |
z-index: -1;
|
32 |
-
background:
|
|
|
|
|
|
|
|
|
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(
|
72 |
-
width DECIMAL(6, 2) CHECK (width > 0 AND width <=
|
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 |
-
|
|
|
39 |
logger.info("Trees table verified in Supabase")
|
|
|
|
|
40 |
return True
|
41 |
except Exception as e:
|
42 |
-
logger.error(f"Failed to verify
|
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
|