Spaces:
Sleeping
Sleeping
feat: dark theme, hover text fixes, login RLS token, AI provider fallback, status cards rendering, optional cutoff, improved error visibility
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .streamlit/secrets.toml +15 -0
- .streamlit/secrets_backup_20250923_195857.toml +15 -0
- COLOR_SCHEME_UPDATE.md +85 -0
- DEPLOYMENT_QUICK_REFERENCE.md +157 -0
- UI_IMPROVEMENTS_GUIDE.md +287 -0
- __pycache__/streamlit_app.cpython-310.pyc +0 -0
- __pycache__/streamlit_app_modern.cpython-310.pyc +0 -0
- __pycache__/ui_improvements.cpython-310.pyc +0 -0
- backups/deployment_log_20250923_202620.json +62 -0
- backups/deployment_log_20250923_203633.json +62 -0
- backups/deployment_log_20250923_204101.json +1 -0
- backups/deployment_log_20250923_204113.json +14 -0
- backups/deployment_log_20250923_205215.json +62 -0
- backups/deployment_log_20250924_092715.json +1 -0
- backups/hero_section_html_fix_20250923_203834/requirements.txt +9 -0
- backups/hero_section_html_fix_20250923_203834/secrets.toml +15 -0
- backups/hero_section_html_fix_20250923_203834/streamlit_app.py +891 -0
- backups/indentation_fix_20250923_203937/requirements.txt +9 -0
- backups/indentation_fix_20250923_203937/secrets.toml +15 -0
- backups/indentation_fix_20250923_203937/streamlit_app.py +891 -0
- backups/pre_modern_deployment_20250923_202615/backup_metadata.json +32 -0
- backups/pre_modern_deployment_20250923_202615/requirements.txt +9 -0
- backups/pre_modern_deployment_20250923_202615/secrets.toml +15 -0
- backups/pre_modern_deployment_20250923_202615/streamlit_app.py +612 -0
- backups/pre_modern_deployment_20250923_202615/streamlit_app_modern.py +891 -0
- backups/pre_modern_deployment_20250923_202615/test_app.py +233 -0
- backups/pre_modern_deployment_20250923_202615/ui_improvements.py +494 -0
- backups/pre_modern_deployment_20250923_203628/backup_metadata.json +34 -0
- backups/pre_modern_deployment_20250923_203628/requirements.txt +9 -0
- backups/pre_modern_deployment_20250923_203628/secrets.toml +15 -0
- backups/pre_modern_deployment_20250923_203628/streamlit_app.py +891 -0
- backups/pre_modern_deployment_20250923_203628/streamlit_app_modern.py +891 -0
- backups/pre_modern_deployment_20250923_203628/test_app.py +233 -0
- backups/pre_modern_deployment_20250923_203628/ui_improvements.py +518 -0
- backups/pre_modern_deployment_20250923_205210/backup_metadata.json +35 -0
- backups/pre_modern_deployment_20250923_205210/requirements.txt +9 -0
- backups/pre_modern_deployment_20250923_205210/secrets.toml +15 -0
- backups/pre_modern_deployment_20250923_205210/streamlit_app.py +890 -0
- backups/pre_modern_deployment_20250923_205210/streamlit_app_modern.py +891 -0
- backups/pre_modern_deployment_20250923_205210/test_app.py +233 -0
- backups/pre_modern_deployment_20250923_205210/ui_improvements.py +583 -0
- backups/pre_test_backup_20250923_202554/requirements.txt +9 -0
- backups/pre_test_backup_20250923_202554/secrets.toml +15 -0
- backups/pre_test_backup_20250923_202554/streamlit_app.py +612 -0
- backups/pre_test_backup_20250923_202613/requirements.txt +9 -0
- backups/pre_test_backup_20250923_202613/secrets.toml +15 -0
- backups/pre_test_backup_20250923_202613/streamlit_app.py +612 -0
- backups/pre_test_backup_20250923_202627/requirements.txt +9 -0
- backups/pre_test_backup_20250923_202627/secrets.toml +15 -0
- backups/pre_test_backup_20250923_202627/streamlit_app.py +891 -0
.streamlit/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
.streamlit/secrets_backup_20250923_195857.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
COLOR_SCHEME_UPDATE.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Care Count - Laurier University Color Scheme Update
|
| 2 |
+
|
| 3 |
+
## 🎨 Color Scheme Changes
|
| 4 |
+
|
| 5 |
+
The Care Count app has been updated to match the Wilfrid Laurier University website color scheme for a more professional and cohesive brand experience.
|
| 6 |
+
|
| 7 |
+
### Updated Color Palette
|
| 8 |
+
|
| 9 |
+
| Element | Old Color | New Color | Description |
|
| 10 |
+
|---------|-----------|-----------|-------------|
|
| 11 |
+
| **Primary Purple** | `#6d28d9` | `#6b46c1` | More vibrant Laurier purple |
|
| 12 |
+
| **Primary Gold** | `#fde047` | `#fbbf24` | Warmer, more professional gold |
|
| 13 |
+
| **Background** | `#0b1420` (dark) | `#f9fafb` (light gray) | Clean, light background |
|
| 14 |
+
| **Secondary Background** | `#0f1a2a` (dark) | `#312e81` (purple) | Subtle purple accent |
|
| 15 |
+
| **Card Background** | Dark theme | White | Clean, professional cards |
|
| 16 |
+
| **Text Color** | `#f3f4f6` (light) | `#1f2937` (dark) | High contrast dark text |
|
| 17 |
+
|
| 18 |
+
### Design Changes
|
| 19 |
+
|
| 20 |
+
#### 1. **Hero Section**
|
| 21 |
+
- **Background**: Solid Laurier purple (`#6b46c1`)
|
| 22 |
+
- **Layout**: Left-aligned text (matching Laurier website)
|
| 23 |
+
- **Typography**: Larger, more prominent headings
|
| 24 |
+
- **Effects**: Subtle gradient overlay for depth
|
| 25 |
+
|
| 26 |
+
#### 2. **Cards & Components**
|
| 27 |
+
- **Background**: Clean white cards with subtle shadows
|
| 28 |
+
- **Borders**: Light gray borders with purple hover effects
|
| 29 |
+
- **Hover Effects**: Subtle lift animation with purple accent
|
| 30 |
+
|
| 31 |
+
#### 3. **Status Cards**
|
| 32 |
+
- **Background**: White with light shadows
|
| 33 |
+
- **Values**: Purple text for metrics
|
| 34 |
+
- **Labels**: Dark gray for better readability
|
| 35 |
+
|
| 36 |
+
#### 4. **Form Elements**
|
| 37 |
+
- **Inputs**: White background with light gray borders
|
| 38 |
+
- **Focus States**: Purple border with subtle glow
|
| 39 |
+
- **Buttons**: Laurier purple with white text
|
| 40 |
+
|
| 41 |
+
#### 5. **Overall Layout**
|
| 42 |
+
- **Background**: Light gray (`#f9fafb`) for better readability
|
| 43 |
+
- **Typography**: Dark text for high contrast
|
| 44 |
+
- **Spacing**: Consistent with Laurier design principles
|
| 45 |
+
|
| 46 |
+
### Benefits of the New Color Scheme
|
| 47 |
+
|
| 48 |
+
1. **Brand Consistency**: Matches Laurier University's official colors
|
| 49 |
+
2. **Better Readability**: Light background with dark text improves accessibility
|
| 50 |
+
3. **Professional Appearance**: Clean, modern design that builds trust
|
| 51 |
+
4. **Improved Contrast**: Better visibility for all users
|
| 52 |
+
5. **Mobile Friendly**: Light theme works better on mobile devices
|
| 53 |
+
|
| 54 |
+
### Technical Implementation
|
| 55 |
+
|
| 56 |
+
The color scheme is implemented using CSS custom properties (variables) in the `ui_improvements.py` file:
|
| 57 |
+
|
| 58 |
+
```css
|
| 59 |
+
:root {
|
| 60 |
+
--primary-purple: #6b46c1; /* Laurier purple */
|
| 61 |
+
--primary-gold: #fbbf24; /* Laurier gold */
|
| 62 |
+
--primary-dark: #1e1b4b; /* Deep purple background */
|
| 63 |
+
--secondary-dark: #312e81; /* Slightly lighter purple */
|
| 64 |
+
/* ... other colors */
|
| 65 |
+
}
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### Accessibility Improvements
|
| 69 |
+
|
| 70 |
+
- **High Contrast**: Dark text on light background meets WCAG guidelines
|
| 71 |
+
- **Focus States**: Clear purple focus indicators
|
| 72 |
+
- **Color Independence**: Information not conveyed by color alone
|
| 73 |
+
- **Readable Typography**: Improved font weights and sizes
|
| 74 |
+
|
| 75 |
+
### Future Considerations
|
| 76 |
+
|
| 77 |
+
The new color scheme provides a solid foundation for:
|
| 78 |
+
- **Dark Mode Toggle**: Easy to implement with CSS variables
|
| 79 |
+
- **Brand Customization**: Colors can be easily adjusted
|
| 80 |
+
- **Seasonal Themes**: Framework supports theme variations
|
| 81 |
+
- **Accessibility**: Built-in support for high contrast modes
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
**Result**: The Care Count app now has a professional, Laurier-branded appearance that matches the university's website while maintaining excellent usability and accessibility.
|
DEPLOYMENT_QUICK_REFERENCE.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Care Count - Deployment Quick Reference
|
| 2 |
+
|
| 3 |
+
## 🚀 Quick Commands
|
| 4 |
+
|
| 5 |
+
### Start the App
|
| 6 |
+
```bash
|
| 7 |
+
streamlit run streamlit_app.py
|
| 8 |
+
```
|
| 9 |
+
**Access at:** http://localhost:8501
|
| 10 |
+
|
| 11 |
+
### Test the App
|
| 12 |
+
```bash
|
| 13 |
+
# Run all tests
|
| 14 |
+
python test_app.py test
|
| 15 |
+
|
| 16 |
+
# Create backup
|
| 17 |
+
python test_app.py backup "description"
|
| 18 |
+
|
| 19 |
+
# Restore from backup
|
| 20 |
+
python test_app.py restore backup_name
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
### Deploy Modern UI
|
| 24 |
+
```bash
|
| 25 |
+
# Deploy with testing
|
| 26 |
+
python deploy_modern.py deploy
|
| 27 |
+
|
| 28 |
+
# List backups
|
| 29 |
+
python deploy_modern.py list
|
| 30 |
+
|
| 31 |
+
# Rollback
|
| 32 |
+
python deploy_modern.py rollback backup_name
|
| 33 |
+
|
| 34 |
+
# Test only
|
| 35 |
+
python deploy_modern.py test
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## 📁 Important Files
|
| 39 |
+
|
| 40 |
+
| File | Purpose |
|
| 41 |
+
|------|---------|
|
| 42 |
+
| `streamlit_app.py` | Main app (now modern version) |
|
| 43 |
+
| `streamlit_app_modern.py` | Modern UI source |
|
| 44 |
+
| `ui_improvements.py` | UI component library |
|
| 45 |
+
| `test_app.py` | Testing framework |
|
| 46 |
+
| `deploy_modern.py` | Deployment script |
|
| 47 |
+
| `.streamlit/secrets.toml` | Configuration |
|
| 48 |
+
| `backups/` | Automatic backups |
|
| 49 |
+
|
| 50 |
+
## 🔧 Configuration
|
| 51 |
+
|
| 52 |
+
### Required Secrets
|
| 53 |
+
```toml
|
| 54 |
+
# .streamlit/secrets.toml
|
| 55 |
+
SUPABASE_URL = "https://your-project.supabase.co"
|
| 56 |
+
SUPABASE_KEY = "your-anon-key"
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### Optional Configuration
|
| 60 |
+
```toml
|
| 61 |
+
APP_TZ = "America/Toronto"
|
| 62 |
+
CUTOFF_HOUR = "20"
|
| 63 |
+
INACTIVITY_MIN = "30"
|
| 64 |
+
PROVIDER = "nebius"
|
| 65 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## 🧪 Testing Checklist
|
| 69 |
+
|
| 70 |
+
- [ ] App starts without errors
|
| 71 |
+
- [ ] Authentication flow works
|
| 72 |
+
- [ ] Visit management functions
|
| 73 |
+
- [ ] Item identification works
|
| 74 |
+
- [ ] Item logging functions
|
| 75 |
+
- [ ] Responsive design works
|
| 76 |
+
- [ ] All buttons and forms work
|
| 77 |
+
|
| 78 |
+
## 🚨 Troubleshooting
|
| 79 |
+
|
| 80 |
+
### App Won't Start
|
| 81 |
+
1. Check if port 8501 is free
|
| 82 |
+
2. Verify all dependencies installed
|
| 83 |
+
3. Check secrets configuration
|
| 84 |
+
4. Review error logs
|
| 85 |
+
|
| 86 |
+
### Authentication Issues
|
| 87 |
+
1. Verify Supabase URL and key
|
| 88 |
+
2. Check network connectivity
|
| 89 |
+
3. Review OTP email delivery
|
| 90 |
+
4. Check database permissions
|
| 91 |
+
|
| 92 |
+
### UI Issues
|
| 93 |
+
1. Clear browser cache
|
| 94 |
+
2. Check for JavaScript errors
|
| 95 |
+
3. Verify CSS loading
|
| 96 |
+
4. Test on different browsers
|
| 97 |
+
|
| 98 |
+
### Rollback Process
|
| 99 |
+
1. List available backups: `python deploy_modern.py list`
|
| 100 |
+
2. Rollback to previous version: `python deploy_modern.py rollback backup_name`
|
| 101 |
+
3. Verify app functionality
|
| 102 |
+
4. Check logs for issues
|
| 103 |
+
|
| 104 |
+
## 📊 Monitoring
|
| 105 |
+
|
| 106 |
+
### Log Files
|
| 107 |
+
- `care_count.log` - Application logs
|
| 108 |
+
- `test_results.log` - Test results
|
| 109 |
+
- `deployment.log` - Deployment logs
|
| 110 |
+
- `backups/deployment_log_*.json` - Deployment history
|
| 111 |
+
|
| 112 |
+
### Health Checks
|
| 113 |
+
- App accessible at http://localhost:8501
|
| 114 |
+
- All UI elements loading
|
| 115 |
+
- Database connectivity working
|
| 116 |
+
- AI services responding
|
| 117 |
+
|
| 118 |
+
## 🔄 Maintenance
|
| 119 |
+
|
| 120 |
+
### Daily
|
| 121 |
+
- Check app accessibility
|
| 122 |
+
- Review error logs
|
| 123 |
+
- Monitor performance
|
| 124 |
+
|
| 125 |
+
### Weekly
|
| 126 |
+
- Run comprehensive tests
|
| 127 |
+
- Review user feedback
|
| 128 |
+
- Check backup integrity
|
| 129 |
+
|
| 130 |
+
### Monthly
|
| 131 |
+
- Update dependencies
|
| 132 |
+
- Review security logs
|
| 133 |
+
- Performance optimization
|
| 134 |
+
|
| 135 |
+
## 📞 Emergency Procedures
|
| 136 |
+
|
| 137 |
+
### App Down
|
| 138 |
+
1. Check if process is running
|
| 139 |
+
2. Review error logs
|
| 140 |
+
3. Restart app: `streamlit run streamlit_app.py`
|
| 141 |
+
4. If issues persist, rollback to last working version
|
| 142 |
+
|
| 143 |
+
### Data Issues
|
| 144 |
+
1. Check database connectivity
|
| 145 |
+
2. Verify Supabase configuration
|
| 146 |
+
3. Review data integrity
|
| 147 |
+
4. Contact database administrator
|
| 148 |
+
|
| 149 |
+
### Security Concerns
|
| 150 |
+
1. Review access logs
|
| 151 |
+
2. Check for unauthorized access
|
| 152 |
+
3. Verify authentication flow
|
| 153 |
+
4. Update credentials if needed
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
**Remember:** Always create a backup before making changes!
|
UI_IMPROVEMENTS_GUIDE.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Care Count - Modern UI/UX Improvements Guide
|
| 2 |
+
|
| 3 |
+
## 🎯 Overview
|
| 4 |
+
|
| 5 |
+
This guide documents the comprehensive UI/UX improvements made to the Care Count application, bringing it to industry-standard quality while maintaining all backend functionality.
|
| 6 |
+
|
| 7 |
+
## 🚀 What's New
|
| 8 |
+
|
| 9 |
+
### 1. Modern Design System
|
| 10 |
+
- **Industry-standard color palette** with Laurier theme (purple & gold)
|
| 11 |
+
- **Consistent typography** using Inter font family
|
| 12 |
+
- **Responsive design** that works on all devices
|
| 13 |
+
- **Accessibility improvements** with proper focus states and ARIA labels
|
| 14 |
+
- **Modern shadows and animations** for better visual hierarchy
|
| 15 |
+
|
| 16 |
+
### 2. Enhanced User Experience
|
| 17 |
+
- **Improved authentication flow** with better error handling
|
| 18 |
+
- **Modern form components** with better validation
|
| 19 |
+
- **Enhanced status cards** with real-time updates
|
| 20 |
+
- **Better visual feedback** for all user actions
|
| 21 |
+
- **Improved loading states** and progress indicators
|
| 22 |
+
|
| 23 |
+
### 3. Professional UI Components
|
| 24 |
+
- **Modern cards and containers** with hover effects
|
| 25 |
+
- **Enhanced buttons** with proper states (primary, secondary, danger)
|
| 26 |
+
- **Better form inputs** with improved styling
|
| 27 |
+
- **Professional badges** for status indicators
|
| 28 |
+
- **Responsive grid layouts** for better organization
|
| 29 |
+
|
| 30 |
+
## 📁 File Structure
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
care-count-starter/
|
| 34 |
+
├── streamlit_app.py # Original app (backed up)
|
| 35 |
+
├── streamlit_app_modern.py # Modern UI version
|
| 36 |
+
├── ui_improvements.py # UI component library
|
| 37 |
+
├── test_app.py # Testing framework
|
| 38 |
+
├── deploy_modern.py # Deployment script
|
| 39 |
+
├── backups/ # Automatic backups
|
| 40 |
+
│ ├── backup_YYYYMMDD_HHMMSS/
|
| 41 |
+
│ └── deployment_log_*.json
|
| 42 |
+
└── UI_IMPROVEMENTS_GUIDE.md # This guide
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## 🛠️ Key Improvements
|
| 46 |
+
|
| 47 |
+
### Authentication Flow
|
| 48 |
+
- **Modern hero section** with gradient background
|
| 49 |
+
- **Better form validation** with clear error messages
|
| 50 |
+
- **Enhanced OTP verification** with improved UX
|
| 51 |
+
- **Professional loading states** during authentication
|
| 52 |
+
|
| 53 |
+
### Dashboard
|
| 54 |
+
- **Modern status cards** with hover effects
|
| 55 |
+
- **Real-time metrics** with better visual presentation
|
| 56 |
+
- **Responsive grid layout** that adapts to screen size
|
| 57 |
+
- **Professional typography** with proper hierarchy
|
| 58 |
+
|
| 59 |
+
### Visit Management
|
| 60 |
+
- **Enhanced visit tracking** with better visual feedback
|
| 61 |
+
- **Modern button styling** with proper states
|
| 62 |
+
- **Improved form layouts** with better spacing
|
| 63 |
+
- **Professional status indicators**
|
| 64 |
+
|
| 65 |
+
### Item Identification
|
| 66 |
+
- **Better camera interface** with improved styling
|
| 67 |
+
- **Enhanced file upload** with drag-and-drop styling
|
| 68 |
+
- **Modern AI feedback** with processing indicators
|
| 69 |
+
- **Professional result display**
|
| 70 |
+
|
| 71 |
+
### Item Logging
|
| 72 |
+
- **Improved form design** with better organization
|
| 73 |
+
- **Enhanced validation** with clear error messages
|
| 74 |
+
- **Modern input styling** with focus states
|
| 75 |
+
- **Professional success/error feedback**
|
| 76 |
+
|
| 77 |
+
## 🧪 Testing & Quality Assurance
|
| 78 |
+
|
| 79 |
+
### Automated Testing
|
| 80 |
+
- **Comprehensive test suite** (`test_app.py`)
|
| 81 |
+
- **UI element validation** to ensure all components work
|
| 82 |
+
- **Performance testing** for load times and responsiveness
|
| 83 |
+
- **Accessibility testing** for proper focus management
|
| 84 |
+
|
| 85 |
+
### Safe Deployment
|
| 86 |
+
- **Automatic backups** before any changes
|
| 87 |
+
- **Rollback capability** to previous versions
|
| 88 |
+
- **Deployment logging** for audit trails
|
| 89 |
+
- **Error handling** with graceful fallbacks
|
| 90 |
+
|
| 91 |
+
## 🔧 Usage Instructions
|
| 92 |
+
|
| 93 |
+
### Running the Modern App
|
| 94 |
+
```bash
|
| 95 |
+
# The modern app is now the default
|
| 96 |
+
streamlit run streamlit_app.py
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### Testing the App
|
| 100 |
+
```bash
|
| 101 |
+
# Run comprehensive tests
|
| 102 |
+
python test_app.py test
|
| 103 |
+
|
| 104 |
+
# Create a backup
|
| 105 |
+
python test_app.py backup "description"
|
| 106 |
+
|
| 107 |
+
# Restore from backup
|
| 108 |
+
python test_app.py restore backup_name
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Deployment Management
|
| 112 |
+
```bash
|
| 113 |
+
# Deploy modern UI (with testing)
|
| 114 |
+
python deploy_modern.py deploy
|
| 115 |
+
|
| 116 |
+
# List available backups
|
| 117 |
+
python deploy_modern.py list
|
| 118 |
+
|
| 119 |
+
# Rollback to previous version
|
| 120 |
+
python deploy_modern.py rollback backup_name
|
| 121 |
+
|
| 122 |
+
# Run tests only
|
| 123 |
+
python deploy_modern.py test
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
## 🎨 Design System
|
| 127 |
+
|
| 128 |
+
### Color Palette
|
| 129 |
+
- **Primary Purple**: `#6d28d9` - Main brand color
|
| 130 |
+
- **Primary Gold**: `#fde047` - Accent color
|
| 131 |
+
- **Dark Background**: `#0b1420` - Main background
|
| 132 |
+
- **Secondary Dark**: `#0f1a2a` - Card backgrounds
|
| 133 |
+
- **Accent Blue**: `#3b82f6` - Information elements
|
| 134 |
+
- **Success Green**: `#10b981` - Success states
|
| 135 |
+
- **Warning Orange**: `#f59e0b` - Warning states
|
| 136 |
+
- **Error Red**: `#ef4444` - Error states
|
| 137 |
+
|
| 138 |
+
### Typography
|
| 139 |
+
- **Font Family**: Inter (system fallbacks)
|
| 140 |
+
- **Headings**: 700 weight, -0.025em letter spacing
|
| 141 |
+
- **Body Text**: 400 weight, 1.6 line height
|
| 142 |
+
- **Small Text**: 0.75rem for captions
|
| 143 |
+
|
| 144 |
+
### Spacing System
|
| 145 |
+
- **Base Unit**: 0.25rem (4px)
|
| 146 |
+
- **Common Spacing**: 1rem, 1.5rem, 2rem, 3rem
|
| 147 |
+
- **Component Padding**: 1rem to 2rem
|
| 148 |
+
- **Grid Gaps**: 1rem to 1.5rem
|
| 149 |
+
|
| 150 |
+
### Border Radius
|
| 151 |
+
- **Small**: 0.375rem (6px)
|
| 152 |
+
- **Medium**: 0.5rem (8px)
|
| 153 |
+
- **Large**: 0.75rem (12px)
|
| 154 |
+
- **Extra Large**: 1rem (16px)
|
| 155 |
+
|
| 156 |
+
## 📱 Responsive Design
|
| 157 |
+
|
| 158 |
+
### Breakpoints
|
| 159 |
+
- **Mobile**: < 768px
|
| 160 |
+
- **Tablet**: 768px - 1024px
|
| 161 |
+
- **Desktop**: > 1024px
|
| 162 |
+
|
| 163 |
+
### Mobile Optimizations
|
| 164 |
+
- **Single column layouts** on mobile
|
| 165 |
+
- **Touch-friendly buttons** (44px minimum)
|
| 166 |
+
- **Optimized spacing** for small screens
|
| 167 |
+
- **Simplified navigation** for mobile users
|
| 168 |
+
|
| 169 |
+
## ♿ Accessibility Features
|
| 170 |
+
|
| 171 |
+
### Keyboard Navigation
|
| 172 |
+
- **Tab order** properly managed
|
| 173 |
+
- **Focus indicators** clearly visible
|
| 174 |
+
- **Keyboard shortcuts** for common actions
|
| 175 |
+
- **Skip links** for screen readers
|
| 176 |
+
|
| 177 |
+
### Screen Reader Support
|
| 178 |
+
- **Semantic HTML** structure
|
| 179 |
+
- **ARIA labels** for complex components
|
| 180 |
+
- **Alt text** for all images
|
| 181 |
+
- **Descriptive link text**
|
| 182 |
+
|
| 183 |
+
### Visual Accessibility
|
| 184 |
+
- **High contrast** color combinations
|
| 185 |
+
- **Large touch targets** (44px minimum)
|
| 186 |
+
- **Clear visual hierarchy** with proper headings
|
| 187 |
+
- **Consistent focus states**
|
| 188 |
+
|
| 189 |
+
## 🔒 Security & Privacy
|
| 190 |
+
|
| 191 |
+
### Enhanced Logging
|
| 192 |
+
- **Comprehensive event logging** for audit trails
|
| 193 |
+
- **User action tracking** for accountability
|
| 194 |
+
- **Error logging** for debugging
|
| 195 |
+
- **Performance monitoring** for optimization
|
| 196 |
+
|
| 197 |
+
### Data Protection
|
| 198 |
+
- **Secure authentication** with OTP
|
| 199 |
+
- **Session management** with automatic timeouts
|
| 200 |
+
- **Input validation** to prevent injection attacks
|
| 201 |
+
- **Error handling** that doesn't expose sensitive data
|
| 202 |
+
|
| 203 |
+
## 🚀 Performance Optimizations
|
| 204 |
+
|
| 205 |
+
### Loading Performance
|
| 206 |
+
- **Optimized CSS** with minimal overhead
|
| 207 |
+
- **Efficient image processing** for AI recognition
|
| 208 |
+
- **Lazy loading** for non-critical components
|
| 209 |
+
- **Caching strategies** for repeated operations
|
| 210 |
+
|
| 211 |
+
### User Experience
|
| 212 |
+
- **Smooth animations** (60fps target)
|
| 213 |
+
- **Instant feedback** for user actions
|
| 214 |
+
- **Progressive loading** for better perceived performance
|
| 215 |
+
- **Error recovery** with graceful fallbacks
|
| 216 |
+
|
| 217 |
+
## 📊 Analytics & Monitoring
|
| 218 |
+
|
| 219 |
+
### User Analytics
|
| 220 |
+
- **Visit tracking** with detailed metrics
|
| 221 |
+
- **Item processing** statistics
|
| 222 |
+
- **User engagement** measurements
|
| 223 |
+
- **Performance metrics** monitoring
|
| 224 |
+
|
| 225 |
+
### System Monitoring
|
| 226 |
+
- **Error tracking** with detailed logs
|
| 227 |
+
- **Performance monitoring** for optimization
|
| 228 |
+
- **Usage analytics** for feature improvement
|
| 229 |
+
- **Health checks** for system reliability
|
| 230 |
+
|
| 231 |
+
## 🔄 Maintenance & Updates
|
| 232 |
+
|
| 233 |
+
### Version Control
|
| 234 |
+
- **Automatic backups** before any changes
|
| 235 |
+
- **Rollback capability** to previous versions
|
| 236 |
+
- **Change logging** for audit trails
|
| 237 |
+
- **Testing framework** for safe updates
|
| 238 |
+
|
| 239 |
+
### Monitoring
|
| 240 |
+
- **Health checks** for system status
|
| 241 |
+
- **Performance monitoring** for optimization
|
| 242 |
+
- **Error tracking** for quick resolution
|
| 243 |
+
- **User feedback** collection for improvements
|
| 244 |
+
|
| 245 |
+
## 🎯 Future Enhancements
|
| 246 |
+
|
| 247 |
+
### Planned Features
|
| 248 |
+
- **Dark/light theme toggle**
|
| 249 |
+
- **Advanced analytics dashboard**
|
| 250 |
+
- **Mobile app version**
|
| 251 |
+
- **Offline capability**
|
| 252 |
+
- **Multi-language support**
|
| 253 |
+
|
| 254 |
+
### Technical Improvements
|
| 255 |
+
- **Microservices architecture**
|
| 256 |
+
- **Real-time updates** with WebSockets
|
| 257 |
+
- **Advanced caching** strategies
|
| 258 |
+
- **Performance optimization** for large datasets
|
| 259 |
+
|
| 260 |
+
## 📞 Support & Documentation
|
| 261 |
+
|
| 262 |
+
### Getting Help
|
| 263 |
+
- **Comprehensive logging** for debugging
|
| 264 |
+
- **Error messages** with helpful suggestions
|
| 265 |
+
- **Documentation** for all features
|
| 266 |
+
- **Testing framework** for validation
|
| 267 |
+
|
| 268 |
+
### Contributing
|
| 269 |
+
- **Clear code structure** for easy maintenance
|
| 270 |
+
- **Comprehensive testing** for reliability
|
| 271 |
+
- **Documentation** for all changes
|
| 272 |
+
- **Version control** for safe updates
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## 🎉 Conclusion
|
| 277 |
+
|
| 278 |
+
The Care Count application now features a modern, industry-standard UI/UX that provides:
|
| 279 |
+
|
| 280 |
+
- **Professional appearance** that builds trust
|
| 281 |
+
- **Intuitive user experience** that reduces training time
|
| 282 |
+
- **Responsive design** that works on all devices
|
| 283 |
+
- **Accessibility features** that include all users
|
| 284 |
+
- **Robust testing** that ensures reliability
|
| 285 |
+
- **Safe deployment** with rollback capabilities
|
| 286 |
+
|
| 287 |
+
The improvements maintain all existing functionality while significantly enhancing the user experience and making the application more maintainable and scalable for future development.
|
__pycache__/streamlit_app.cpython-310.pyc
ADDED
|
Binary file (27.1 kB). View file
|
|
|
__pycache__/streamlit_app_modern.cpython-310.pyc
ADDED
|
Binary file (27.1 kB). View file
|
|
|
__pycache__/ui_improvements.cpython-310.pyc
ADDED
|
Binary file (20.7 kB). View file
|
|
|
backups/deployment_log_20250923_202620.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"timestamp": "2025-09-23T20:26:01.318778",
|
| 4 |
+
"step": "testing",
|
| 5 |
+
"status": "STARTED",
|
| 6 |
+
"details": "Running pre-deployment tests"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"timestamp": "2025-09-23T20:26:12.234228",
|
| 10 |
+
"step": "import_test",
|
| 11 |
+
"status": "SUCCESS",
|
| 12 |
+
"details": "Modern app imports successfully"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"timestamp": "2025-09-23T20:26:12.235170",
|
| 16 |
+
"step": "ui_import_test",
|
| 17 |
+
"status": "SUCCESS",
|
| 18 |
+
"details": "UI improvements import successfully"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"timestamp": "2025-09-23T20:26:13.554936",
|
| 22 |
+
"step": "app_tests",
|
| 23 |
+
"status": "SUCCESS",
|
| 24 |
+
"details": "All app tests passed"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"timestamp": "2025-09-23T20:26:13.555445",
|
| 28 |
+
"step": "testing",
|
| 29 |
+
"status": "SUCCESS",
|
| 30 |
+
"details": "All tests passed"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"timestamp": "2025-09-23T20:26:13.555876",
|
| 34 |
+
"step": "deployment",
|
| 35 |
+
"status": "STARTED",
|
| 36 |
+
"details": "Deploying modern UI"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"timestamp": "2025-09-23T20:26:15.675932",
|
| 40 |
+
"step": "stop_app",
|
| 41 |
+
"status": "SUCCESS",
|
| 42 |
+
"details": "Stopped current app"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"timestamp": "2025-09-23T20:26:15.826651",
|
| 46 |
+
"step": "backup_creation",
|
| 47 |
+
"status": "SUCCESS",
|
| 48 |
+
"details": "Created backup: pre_modern_deployment_20250923_202615"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"timestamp": "2025-09-23T20:26:15.830996",
|
| 52 |
+
"step": "file_replacement",
|
| 53 |
+
"status": "SUCCESS",
|
| 54 |
+
"details": "Replaced app with modern version"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"timestamp": "2025-09-23T20:26:20.853515",
|
| 58 |
+
"step": "app_start",
|
| 59 |
+
"status": "SUCCESS",
|
| 60 |
+
"details": "Modern app started successfully"
|
| 61 |
+
}
|
| 62 |
+
]
|
backups/deployment_log_20250923_203633.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"timestamp": "2025-09-23T20:36:21.190830",
|
| 4 |
+
"step": "testing",
|
| 5 |
+
"status": "STARTED",
|
| 6 |
+
"details": "Running pre-deployment tests"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"timestamp": "2025-09-23T20:36:25.672280",
|
| 10 |
+
"step": "import_test",
|
| 11 |
+
"status": "SUCCESS",
|
| 12 |
+
"details": "Modern app imports successfully"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"timestamp": "2025-09-23T20:36:25.672495",
|
| 16 |
+
"step": "ui_import_test",
|
| 17 |
+
"status": "SUCCESS",
|
| 18 |
+
"details": "UI improvements import successfully"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"timestamp": "2025-09-23T20:36:26.198747",
|
| 22 |
+
"step": "app_tests",
|
| 23 |
+
"status": "SUCCESS",
|
| 24 |
+
"details": "All app tests passed"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"timestamp": "2025-09-23T20:36:26.199337",
|
| 28 |
+
"step": "testing",
|
| 29 |
+
"status": "SUCCESS",
|
| 30 |
+
"details": "All tests passed"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"timestamp": "2025-09-23T20:36:26.199731",
|
| 34 |
+
"step": "deployment",
|
| 35 |
+
"status": "STARTED",
|
| 36 |
+
"details": "Deploying modern UI"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"timestamp": "2025-09-23T20:36:28.278741",
|
| 40 |
+
"step": "stop_app",
|
| 41 |
+
"status": "SUCCESS",
|
| 42 |
+
"details": "Stopped current app"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"timestamp": "2025-09-23T20:36:28.394861",
|
| 46 |
+
"step": "backup_creation",
|
| 47 |
+
"status": "SUCCESS",
|
| 48 |
+
"details": "Created backup: pre_modern_deployment_20250923_203628"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"timestamp": "2025-09-23T20:36:28.398794",
|
| 52 |
+
"step": "file_replacement",
|
| 53 |
+
"status": "SUCCESS",
|
| 54 |
+
"details": "Replaced app with modern version"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"timestamp": "2025-09-23T20:36:33.420662",
|
| 58 |
+
"step": "app_start",
|
| 59 |
+
"status": "SUCCESS",
|
| 60 |
+
"details": "Modern app started successfully"
|
| 61 |
+
}
|
| 62 |
+
]
|
backups/deployment_log_20250923_204101.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/deployment_log_20250923_204113.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"timestamp": "2025-09-23T20:41:07.876272",
|
| 4 |
+
"step": "rollback",
|
| 5 |
+
"status": "STARTED",
|
| 6 |
+
"details": "Rolling back to pre_modern_deployment_20250923_203628"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"timestamp": "2025-09-23T20:41:13.010207",
|
| 10 |
+
"step": "rollback",
|
| 11 |
+
"status": "SUCCESS",
|
| 12 |
+
"details": "Rolled back to pre_modern_deployment_20250923_203628"
|
| 13 |
+
}
|
| 14 |
+
]
|
backups/deployment_log_20250923_205215.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"timestamp": "2025-09-23T20:52:02.354828",
|
| 4 |
+
"step": "testing",
|
| 5 |
+
"status": "STARTED",
|
| 6 |
+
"details": "Running pre-deployment tests"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"timestamp": "2025-09-23T20:52:07.829354",
|
| 10 |
+
"step": "import_test",
|
| 11 |
+
"status": "SUCCESS",
|
| 12 |
+
"details": "Modern app imports successfully"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"timestamp": "2025-09-23T20:52:07.829616",
|
| 16 |
+
"step": "ui_import_test",
|
| 17 |
+
"status": "SUCCESS",
|
| 18 |
+
"details": "UI improvements import successfully"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"timestamp": "2025-09-23T20:52:08.502096",
|
| 22 |
+
"step": "app_tests",
|
| 23 |
+
"status": "SUCCESS",
|
| 24 |
+
"details": "All app tests passed"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"timestamp": "2025-09-23T20:52:08.502609",
|
| 28 |
+
"step": "testing",
|
| 29 |
+
"status": "SUCCESS",
|
| 30 |
+
"details": "All tests passed"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"timestamp": "2025-09-23T20:52:08.503427",
|
| 34 |
+
"step": "deployment",
|
| 35 |
+
"status": "STARTED",
|
| 36 |
+
"details": "Deploying modern UI"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"timestamp": "2025-09-23T20:52:10.618570",
|
| 40 |
+
"step": "stop_app",
|
| 41 |
+
"status": "SUCCESS",
|
| 42 |
+
"details": "Stopped current app"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"timestamp": "2025-09-23T20:52:10.729497",
|
| 46 |
+
"step": "backup_creation",
|
| 47 |
+
"status": "SUCCESS",
|
| 48 |
+
"details": "Created backup: pre_modern_deployment_20250923_205210"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"timestamp": "2025-09-23T20:52:10.733425",
|
| 52 |
+
"step": "file_replacement",
|
| 53 |
+
"status": "SUCCESS",
|
| 54 |
+
"details": "Replaced app with modern version"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"timestamp": "2025-09-23T20:52:15.751869",
|
| 58 |
+
"step": "app_start",
|
| 59 |
+
"status": "SUCCESS",
|
| 60 |
+
"details": "Modern app started successfully"
|
| 61 |
+
}
|
| 62 |
+
]
|
backups/deployment_log_20250924_092715.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/hero_section_html_fix_20250923_203834/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/hero_section_html_fix_20250923_203834/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/hero_section_html_fix_20250923_203834/streamlit_app.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
), unsafe_allow_html=True)
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/indentation_fix_20250923_203937/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/indentation_fix_20250923_203937/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/indentation_fix_20250923_203937/streamlit_app.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
), unsafe_allow_html=True)
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_202615/backup_metadata.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backup_name": "pre_modern_deployment_20250923_202615",
|
| 3 |
+
"timestamp": "20250923_202615",
|
| 4 |
+
"description": "pre_modern_deployment",
|
| 5 |
+
"files_backed_up": [
|
| 6 |
+
"streamlit_app.py",
|
| 7 |
+
"streamlit_app_modern.py",
|
| 8 |
+
".streamlit/secrets.toml",
|
| 9 |
+
"requirements.txt",
|
| 10 |
+
"ui_improvements.py",
|
| 11 |
+
"test_app.py"
|
| 12 |
+
],
|
| 13 |
+
"git_status": {
|
| 14 |
+
"modified_files": [
|
| 15 |
+
"M requirements.txt",
|
| 16 |
+
" M streamlit_app.py",
|
| 17 |
+
"?? .streamlit/",
|
| 18 |
+
"?? .venv/",
|
| 19 |
+
"?? __pycache__/",
|
| 20 |
+
"?? backups/",
|
| 21 |
+
"?? care_count.log",
|
| 22 |
+
"?? deploy_modern.py",
|
| 23 |
+
"?? deployment.log",
|
| 24 |
+
"?? streamlit_app_backup_20250923_195853.py",
|
| 25 |
+
"?? streamlit_app_modern.py",
|
| 26 |
+
"?? test_app.py",
|
| 27 |
+
"?? test_results.log",
|
| 28 |
+
"?? ui_improvements.py"
|
| 29 |
+
],
|
| 30 |
+
"return_code": 0
|
| 31 |
+
}
|
| 32 |
+
}
|
backups/pre_modern_deployment_20250923_202615/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_modern_deployment_20250923_202615/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_modern_deployment_20250923_202615/streamlit_app.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Delightful Laurier-themed UX + enterprise data hygiene
|
| 3 |
+
# - OTP email login (Supabase)
|
| 4 |
+
# - Shift tracking & idle/8pm auto sign-out
|
| 5 |
+
# - Human-friendly visit_code (with DB trigger or safe fallback)
|
| 6 |
+
# - VLM-assisted item name
|
| 7 |
+
# - RPC ingest with validation/quarantine (fallback to direct insert)
|
| 8 |
+
# - Volunteer Impact card (today + lifetime)
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os, io, time, base64, re, uuid, json
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import pytz
|
| 17 |
+
import requests
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import streamlit as st
|
| 20 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 21 |
+
from supabase import create_client, Client
|
| 22 |
+
|
| 23 |
+
# ------------------------ App config ------------------------
|
| 24 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 25 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 26 |
+
|
| 27 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 28 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 29 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 30 |
+
|
| 31 |
+
def local_now() -> datetime:
|
| 32 |
+
return datetime.now(pytz.timezone(TZ))
|
| 33 |
+
|
| 34 |
+
st.set_page_config(page_title="Care Count", layout="centered")
|
| 35 |
+
st.markdown("""
|
| 36 |
+
<style>
|
| 37 |
+
/* Laurier theme vibes */
|
| 38 |
+
:root { --cc-purple:#6d28d9; --cc-gold:#fde047; --cc-bg:#0b1420; --cc-panel:#0f1a2a; --cc-border:#1d2a44; }
|
| 39 |
+
.block-container { padding-top: 2rem; }
|
| 40 |
+
h1, h2, .stMarkdown h1, .stMarkdown h2 { letter-spacing:.2px }
|
| 41 |
+
.cc-pill { display:inline-block; padding:4px 10px; border-radius:999px; background:var(--cc-gold); color:#111827; font-weight:700; font-size:12px; }
|
| 42 |
+
.cc-hint { background:#10233b; border:1px solid #1f3b5b; color:#e6e8f0; padding:12px 16px; border-radius:10px; }
|
| 43 |
+
.cc-hero { background:#0f1a2a; border:1px solid #1f2a44; padding:16px 18px; border-radius:14px; }
|
| 44 |
+
.cc-btn-primary button { background:var(--cc-purple)!important; color:#fff!important; border:0!important; }
|
| 45 |
+
.cc-danger button { background:#7f1d1d!important; color:#fff!important; }
|
| 46 |
+
.status-card { display:flex; gap:16px; flex-wrap:wrap; }
|
| 47 |
+
.card { background:#0f1a2a; border:1px solid #1f2a44; border-radius:14px; padding:14px 16px; min-width:200px; }
|
| 48 |
+
.card h4 { margin:0 0 6px 0; font-size:0.95rem; color:#cbd5e1 }
|
| 49 |
+
.card .big { font-size:1.6rem; font-weight:700; color:#e5e7eb }
|
| 50 |
+
.small { color:#9aa3b2; font-size:12px }
|
| 51 |
+
</style>
|
| 52 |
+
""", unsafe_allow_html=True)
|
| 53 |
+
|
| 54 |
+
st.title("💜💛 Care Count")
|
| 55 |
+
st.caption("Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time.")
|
| 56 |
+
|
| 57 |
+
# ------------------------ Secrets & client ------------------------
|
| 58 |
+
# Robust env/secrets helpers that work with Streamlit (st.secrets) and .env/CI.
|
| 59 |
+
# Order of precedence: OS env > Streamlit secrets > default.
|
| 60 |
+
|
| 61 |
+
# Optional: allow plain `python` runs to pick up a local .env if present
|
| 62 |
+
try:
|
| 63 |
+
from dotenv import load_dotenv # pip install python-dotenv (optional)
|
| 64 |
+
load_dotenv()
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Make Streamlit secrets safe to access even when not launched via `streamlit run`
|
| 69 |
+
try:
|
| 70 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 71 |
+
except Exception:
|
| 72 |
+
_SECRETS = {}
|
| 73 |
+
|
| 74 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 75 |
+
return val is not None and str(val).strip() != ""
|
| 76 |
+
|
| 77 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 78 |
+
"""
|
| 79 |
+
Flat TOML usage:
|
| 80 |
+
SUPABASE_URL = "..."
|
| 81 |
+
SUPABASE_KEY = "..."
|
| 82 |
+
Looks in OS env first, then st.secrets, else returns default.
|
| 83 |
+
"""
|
| 84 |
+
# 1) OS env
|
| 85 |
+
v = os.getenv(name)
|
| 86 |
+
if _is_useful(v):
|
| 87 |
+
return v
|
| 88 |
+
|
| 89 |
+
# 2) Streamlit secrets (flat)
|
| 90 |
+
try:
|
| 91 |
+
if hasattr(_SECRETS, "get"):
|
| 92 |
+
v = _SECRETS.get(name, default)
|
| 93 |
+
if _is_useful(v):
|
| 94 |
+
return v
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
# 3) default
|
| 99 |
+
return default
|
| 100 |
+
|
| 101 |
+
def require_secret(name: str) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Same as get_secret but fails fast with a clear error if missing/blank.
|
| 104 |
+
"""
|
| 105 |
+
v = get_secret(name, None)
|
| 106 |
+
if not _is_useful(v):
|
| 107 |
+
st.error(
|
| 108 |
+
f"Missing secret: {name}. "
|
| 109 |
+
"Ensure it exists as an environment variable or in .streamlit/secrets.toml"
|
| 110 |
+
)
|
| 111 |
+
st.stop()
|
| 112 |
+
return str(v)
|
| 113 |
+
|
| 114 |
+
# (Optional) lightweight visibility check during setup. Comment out in prod.
|
| 115 |
+
# st.write("Secrets present:",
|
| 116 |
+
# bool(get_secret("SUPABASE_URL")),
|
| 117 |
+
# bool(get_secret("SUPABASE_KEY")))
|
| 118 |
+
|
| 119 |
+
# Required Supabase credentials (flat keys)
|
| 120 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 121 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY") # anon key (RLS should be ON)
|
| 122 |
+
|
| 123 |
+
# Initialize Supabase client
|
| 124 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 125 |
+
|
| 126 |
+
# Optional provider/config (all flat keys with safe defaults)
|
| 127 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 128 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 129 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 130 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 131 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 132 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
st.caption(f"Provider: `{PROVIDER}` · Model: `{GEMMA_MODEL}` · TZ: `{TZ}`")
|
| 137 |
+
|
| 138 |
+
# ------------------------ Light events/audit (non-blocking) ------------------------
|
| 139 |
+
def log_event(action: str, actor: Optional[str], details: dict):
|
| 140 |
+
try:
|
| 141 |
+
sb.table("events").insert({"actor_email": actor, "action": action, "details": details}).execute()
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
# ------------------------ Auth (Email OTP) ------------------------
|
| 146 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 147 |
+
if "auth_email" not in st.session_state:
|
| 148 |
+
st.session_state["auth_email"] = None
|
| 149 |
+
if "user_email" in st.session_state:
|
| 150 |
+
return True, st.session_state["user_email"]
|
| 151 |
+
|
| 152 |
+
st.subheader("Sign in")
|
| 153 |
+
with st.form("otp_request", clear_on_submit=False, border=False):
|
| 154 |
+
email = st.text_input("Email", value=st.session_state.get("auth_email") or "", autocomplete="email")
|
| 155 |
+
send = st.form_submit_button("Send login code")
|
| 156 |
+
if send:
|
| 157 |
+
if not email or "@" not in email:
|
| 158 |
+
st.error("Please enter a valid email.")
|
| 159 |
+
else:
|
| 160 |
+
try:
|
| 161 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 162 |
+
st.session_state["auth_email"] = email
|
| 163 |
+
st.success("We emailed you a one-time code. Enter it to continue.")
|
| 164 |
+
except Exception as e:
|
| 165 |
+
st.error(f"Could not send code: {e}")
|
| 166 |
+
|
| 167 |
+
if st.session_state.get("auth_email"):
|
| 168 |
+
with st.form("otp_verify", clear_on_submit=True, border=False):
|
| 169 |
+
code = st.text_input("Enter 6-digit code", max_chars=6)
|
| 170 |
+
ok = st.form_submit_button("Verify & start shift")
|
| 171 |
+
if ok:
|
| 172 |
+
try:
|
| 173 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 174 |
+
if res and res.user:
|
| 175 |
+
email = st.session_state["auth_email"]
|
| 176 |
+
# Idempotent upsert for volunteer & start shift
|
| 177 |
+
sb.table("volunteers").upsert({
|
| 178 |
+
"email": email,
|
| 179 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 180 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 181 |
+
"shift_ended_at": None
|
| 182 |
+
}, on_conflict="email").execute()
|
| 183 |
+
st.session_state["user_email"] = email
|
| 184 |
+
st.session_state["shift_started"] = True
|
| 185 |
+
st.session_state["last_activity_at"] = local_now()
|
| 186 |
+
log_event("login", email, {"method":"otp"})
|
| 187 |
+
st.toast("Welcome back! Shift started. 💜", icon="✅")
|
| 188 |
+
return True, email
|
| 189 |
+
except Exception as e:
|
| 190 |
+
st.error(f"Verification failed: {e}")
|
| 191 |
+
return False, None
|
| 192 |
+
|
| 193 |
+
def end_shift(email: str, reason: str):
|
| 194 |
+
try:
|
| 195 |
+
sb.table("volunteers").update({"shift_ended_at": datetime.utcnow().isoformat()}).eq("email", email).execute()
|
| 196 |
+
log_event("shift_end", email, {"reason": reason})
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
def guard_cutoff_and_idle(email: str):
|
| 201 |
+
now = local_now()
|
| 202 |
+
last = st.session_state.get("last_activity_at")
|
| 203 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 204 |
+
end_shift(email, "inactivity")
|
| 205 |
+
st.session_state.clear()
|
| 206 |
+
st.info("You were logged out due to inactivity. Thank you for volunteering today!")
|
| 207 |
+
st.stop()
|
| 208 |
+
st.session_state["last_activity_at"] = now
|
| 209 |
+
|
| 210 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 211 |
+
if now >= cutoff:
|
| 212 |
+
end_shift(email, "cutoff_8pm")
|
| 213 |
+
st.session_state.clear()
|
| 214 |
+
st.info("We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 215 |
+
st.stop()
|
| 216 |
+
|
| 217 |
+
signed_in, user_email = auth_block()
|
| 218 |
+
if not signed_in:
|
| 219 |
+
st.stop()
|
| 220 |
+
guard_cutoff_and_idle(user_email)
|
| 221 |
+
|
| 222 |
+
# Welcome banner (visceral, kind)
|
| 223 |
+
st.markdown(
|
| 224 |
+
f"""<div class="cc-hero">
|
| 225 |
+
<div class="small">Welcome,</div>
|
| 226 |
+
<div style="font-weight:800;font-size:1.15rem"> {user_email}</div>
|
| 227 |
+
<div class="small">Thank you for showing up for the community today. 💜💛</div>
|
| 228 |
+
</div>""",
|
| 229 |
+
unsafe_allow_html=True
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Sign-out button (prominent)
|
| 233 |
+
c_signout = st.columns([1,1,6])[1]
|
| 234 |
+
with c_signout:
|
| 235 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 236 |
+
if st.button("🔒 Sign out"):
|
| 237 |
+
end_shift(user_email, "manual")
|
| 238 |
+
st.session_state.clear()
|
| 239 |
+
st.success("Signed out. See you next time!")
|
| 240 |
+
st.stop()
|
| 241 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 242 |
+
|
| 243 |
+
# ------------------------ Volunteer Impact card ------------------------
|
| 244 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 245 |
+
try:
|
| 246 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 247 |
+
except Exception:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
def items_today(email: str) -> int:
|
| 251 |
+
try:
|
| 252 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 253 |
+
# try partitioned table first
|
| 254 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 255 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 256 |
+
.eq("volunteer", email).execute().data
|
| 257 |
+
return len(data or [])
|
| 258 |
+
except Exception:
|
| 259 |
+
try:
|
| 260 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 261 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 262 |
+
.eq("volunteer", email).execute().data
|
| 263 |
+
return len(data or [])
|
| 264 |
+
except Exception:
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 268 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 269 |
+
lifetime_hours = vrow.get("total_hours") # may not exist yet; handled below
|
| 270 |
+
mins_active = 0
|
| 271 |
+
try:
|
| 272 |
+
if shift_started_at:
|
| 273 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 274 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
|
| 278 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 279 |
+
st.markdown(f'<div class="card"><h4>Shift active</h4><div class="big">{mins_active} min</div><div class="small">since you signed in</div></div>', unsafe_allow_html=True)
|
| 280 |
+
st.markdown(f'<div class="card"><h4>Items you logged today</h4><div class="big">{items_today(user_email)}</div></div>', unsafe_allow_html=True)
|
| 281 |
+
if isinstance(lifetime_hours, (int, float)):
|
| 282 |
+
st.markdown(f'<div class="card"><h4>Lifetime hours</h4><div class="big">{round(float(lifetime_hours),1)}</div></div>', unsafe_allow_html=True)
|
| 283 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 284 |
+
|
| 285 |
+
# ------------------------ Image helpers ------------------------
|
| 286 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 287 |
+
b = io.BytesIO(); img.save(b, format="PNG"); return b.getvalue()
|
| 288 |
+
|
| 289 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 290 |
+
img = img.convert("RGB")
|
| 291 |
+
w, h = img.size
|
| 292 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 293 |
+
if scale < 1.0: img = img.resize((int(w * scale), int(h * scale)))
|
| 294 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 295 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 296 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 297 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 298 |
+
return img
|
| 299 |
+
|
| 300 |
+
# ------------------------ VLM client ------------------------
|
| 301 |
+
SYSTEM_HINT = "You label item being held in the image for a food bank. Return ONLY the item name."
|
| 302 |
+
|
| 303 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 304 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 305 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 306 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 307 |
+
payload = {
|
| 308 |
+
"model": model_id,
|
| 309 |
+
"temperature": 0,
|
| 310 |
+
"messages": [
|
| 311 |
+
{"role": "system", "content": SYSTEM_HINT},
|
| 312 |
+
{"role": "user", "content": [
|
| 313 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 314 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 315 |
+
]}
|
| 316 |
+
]
|
| 317 |
+
}
|
| 318 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 319 |
+
if r.status_code != 200:
|
| 320 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 321 |
+
data = r.json()
|
| 322 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 323 |
+
|
| 324 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 325 |
+
if PROVIDER == "nebius":
|
| 326 |
+
if not NEBIUS_API_KEY: raise RuntimeError("NEBIUS_API_KEY missing")
|
| 327 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 328 |
+
elif PROVIDER == "featherless":
|
| 329 |
+
if not FEATH_API_KEY: raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 330 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 331 |
+
else:
|
| 332 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 333 |
+
|
| 334 |
+
# ------------------------ Normalization ------------------------
|
| 335 |
+
BRANDS = {
|
| 336 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 337 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 338 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 339 |
+
}
|
| 340 |
+
GENERIC_TYPES = {
|
| 341 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 342 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 343 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 344 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 345 |
+
}
|
| 346 |
+
def normalize_item_name(s: str) -> str:
|
| 347 |
+
s = (s or "").strip()
|
| 348 |
+
if not s: return ""
|
| 349 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 350 |
+
for b in BRANDS: low = low.replace(b, "")
|
| 351 |
+
chosen = None
|
| 352 |
+
for t in GENERIC_TYPES:
|
| 353 |
+
if t in low: chosen = t; break
|
| 354 |
+
cleaned = " ".join(low.split())
|
| 355 |
+
return (chosen or cleaned.title())[:120]
|
| 356 |
+
|
| 357 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 358 |
+
if not v: return None
|
| 359 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 360 |
+
return v[:maxlen] if v else None
|
| 361 |
+
|
| 362 |
+
# ------------------------ Visit flow ------------------------
|
| 363 |
+
st.subheader("🪪 Anonymous Student Visit")
|
| 364 |
+
|
| 365 |
+
active_visit = st.session_state.get("active_visit")
|
| 366 |
+
|
| 367 |
+
def fallback_visit_code() -> str:
|
| 368 |
+
"""Readable visit_code if DB trigger isn't present."""
|
| 369 |
+
try:
|
| 370 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 371 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 372 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 373 |
+
seq = len(todays) + 1
|
| 374 |
+
except Exception:
|
| 375 |
+
seq = int(time.time()) % 1000
|
| 376 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 377 |
+
|
| 378 |
+
c1, c2, c3 = st.columns(3)
|
| 379 |
+
with c1:
|
| 380 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 381 |
+
if not active_visit and st.button("Start Visit"):
|
| 382 |
+
try:
|
| 383 |
+
payload = {
|
| 384 |
+
"visit_code": fallback_visit_code(), # DB trigger will override if present
|
| 385 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 386 |
+
"ended_at": None,
|
| 387 |
+
"created_by": user_email
|
| 388 |
+
}
|
| 389 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 390 |
+
# If trigger generated code, keep that
|
| 391 |
+
if not v.get("visit_code"):
|
| 392 |
+
v["visit_code"] = payload["visit_code"]
|
| 393 |
+
st.session_state["active_visit"] = v
|
| 394 |
+
st.success(f"Visit #{v['id']} started · code: **{v['visit_code']}**")
|
| 395 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 396 |
+
except Exception as e:
|
| 397 |
+
st.error(f"Could not start visit: {e}")
|
| 398 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 399 |
+
|
| 400 |
+
with c2:
|
| 401 |
+
if active_visit and st.button("End Visit (Checkout)"):
|
| 402 |
+
try:
|
| 403 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 404 |
+
.eq("id", active_visit["id"]).execute()
|
| 405 |
+
st.success("Visit checked out. Ready for the next student.")
|
| 406 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 407 |
+
st.session_state.pop("active_visit", None)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
st.error(f"Could not end visit: {e}")
|
| 410 |
+
|
| 411 |
+
with c3:
|
| 412 |
+
if st.session_state.get("active_visit"):
|
| 413 |
+
v = st.session_state["active_visit"]
|
| 414 |
+
st.caption(f"Active visit_id: {v['id']} · code: {v.get('visit_code','')}")
|
| 415 |
+
else:
|
| 416 |
+
st.caption("No active visit.")
|
| 417 |
+
|
| 418 |
+
# ------------------------ Identify item ------------------------
|
| 419 |
+
st.subheader("📸 Identify item from image")
|
| 420 |
+
|
| 421 |
+
c4, c5 = st.columns(2)
|
| 422 |
+
with c4: cam = st.camera_input("Use your phone or webcam")
|
| 423 |
+
with c5: up = st.file_uploader("…or upload an image", type=["png","jpg","jpeg"])
|
| 424 |
+
|
| 425 |
+
img_file = cam or up
|
| 426 |
+
if img_file:
|
| 427 |
+
img = Image.open(img_file).convert("RGB")
|
| 428 |
+
st.image(img, use_container_width=True)
|
| 429 |
+
if st.button("🔍 Ask model for item name"):
|
| 430 |
+
t0, raw = time.time(), ""
|
| 431 |
+
try:
|
| 432 |
+
pre = preprocess_for_label(img); raw = gemma_item_name(_to_png_bytes(pre))
|
| 433 |
+
except Exception as e:
|
| 434 |
+
st.error(f"Provider error: {e}"); log_event("vlm_error", user_email, {"error": str(e)})
|
| 435 |
+
norm = normalize_item_name(raw)
|
| 436 |
+
if raw: st.success(f"🧠 Model: **{raw}**")
|
| 437 |
+
st.info(f"✨ Normalized: **{norm or '(unknown)'}** · ⏱️ {time.time()-t0:.2f}s")
|
| 438 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 439 |
+
st.session_state["last_activity_at"] = local_now()
|
| 440 |
+
|
| 441 |
+
# ------------------------ Log item ------------------------
|
| 442 |
+
st.subheader("📬 Log item to current visit")
|
| 443 |
+
|
| 444 |
+
item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name",""))
|
| 445 |
+
quantity = st.number_input("Quantity", min_value=1, max_value=9999, step=1, value=1)
|
| 446 |
+
category = st.text_input("Category (optional)")
|
| 447 |
+
unit = st.text_input("Unit (optional, e.g., 500 mL, 1 L, 250 g)")
|
| 448 |
+
barcode = st.text_input("Barcode (optional)")
|
| 449 |
+
|
| 450 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 451 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 452 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 453 |
+
|
| 454 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 455 |
+
category: Optional[str], unit: Optional[str],
|
| 456 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 457 |
+
"""
|
| 458 |
+
Call the RPC safe_ingest_visit_item if it exists.
|
| 459 |
+
Returns (ok, msg). If function is missing, raises to trigger fallback.
|
| 460 |
+
"""
|
| 461 |
+
try:
|
| 462 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 463 |
+
"p_email": email,
|
| 464 |
+
"p_visit_id": v_id,
|
| 465 |
+
"p_item_name": name,
|
| 466 |
+
"p_qty": qty,
|
| 467 |
+
"p_category": category,
|
| 468 |
+
"p_unit": unit,
|
| 469 |
+
"p_barcode": barcode,
|
| 470 |
+
"p_ts": ts_iso,
|
| 471 |
+
"p_ingest_id": ingest_id
|
| 472 |
+
}).execute()
|
| 473 |
+
# RPC returns a setof (ok boolean, msg text) — normalize
|
| 474 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 475 |
+
if rows:
|
| 476 |
+
r0 = rows[0]
|
| 477 |
+
ok = bool(r0.get("ok", False))
|
| 478 |
+
msg = str(r0.get("msg", ""))
|
| 479 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 480 |
+
# Some PostgREST setups return {} — treat as ok
|
| 481 |
+
return True, "ok"
|
| 482 |
+
except Exception as e:
|
| 483 |
+
# If function missing (42883) or not exposed, re-raise to use fallback
|
| 484 |
+
raise e
|
| 485 |
+
|
| 486 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 487 |
+
category: Optional[str], unit: Optional[str],
|
| 488 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 489 |
+
"""Write into visit_items_p if present, else visit_items (keeps app working)."""
|
| 490 |
+
payload = {
|
| 491 |
+
"visit_id": v_id,
|
| 492 |
+
"timestamp": ts_iso,
|
| 493 |
+
"volunteer": email,
|
| 494 |
+
"item_name": name,
|
| 495 |
+
"category": category,
|
| 496 |
+
"unit": unit,
|
| 497 |
+
"qty": qty,
|
| 498 |
+
"barcode": barcode,
|
| 499 |
+
"weather_type": None,
|
| 500 |
+
"temp_c": None,
|
| 501 |
+
"ingest_id": ingest_id
|
| 502 |
+
}
|
| 503 |
+
try:
|
| 504 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 505 |
+
except Exception:
|
| 506 |
+
# legacy table fallback
|
| 507 |
+
payload.pop("ingest_id", None)
|
| 508 |
+
sb.table("visit_items").insert(payload).execute()
|
| 509 |
+
|
| 510 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 511 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 512 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled):
|
| 513 |
+
v = st.session_state.get("active_visit")
|
| 514 |
+
if not v:
|
| 515 |
+
st.warning("Start a visit first, then save items.")
|
| 516 |
+
else:
|
| 517 |
+
name_clean = clean_text(item_name, 120)
|
| 518 |
+
if not name_clean:
|
| 519 |
+
st.warning("Item name is required.")
|
| 520 |
+
else:
|
| 521 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 522 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 523 |
+
try:
|
| 524 |
+
ok, msg = try_rpc_ingest(
|
| 525 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 526 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 527 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 528 |
+
)
|
| 529 |
+
if ok:
|
| 530 |
+
st.success("Item logged ✅")
|
| 531 |
+
else:
|
| 532 |
+
st.warning(f"Ingest said: {msg}. (Will try direct insert.)")
|
| 533 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 534 |
+
clean_text(category,80), clean_text(unit,40),
|
| 535 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 536 |
+
st.success("Item logged ✅ (fallback)")
|
| 537 |
+
except Exception as e:
|
| 538 |
+
# RPC missing or failed → direct insert
|
| 539 |
+
try:
|
| 540 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 541 |
+
clean_text(category,80), clean_text(unit,40),
|
| 542 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 543 |
+
st.success("Item logged ✅ (fallback)")
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"Ingest failed: {e2}")
|
| 546 |
+
st.session_state["last_activity_at"] = local_now()
|
| 547 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 548 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 549 |
+
|
| 550 |
+
# ------------------------ Visit items view + delete ------------------------
|
| 551 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 552 |
+
# Prefer partitioned table
|
| 553 |
+
try:
|
| 554 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 555 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 556 |
+
except Exception:
|
| 557 |
+
try:
|
| 558 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 559 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 560 |
+
except Exception:
|
| 561 |
+
return []
|
| 562 |
+
|
| 563 |
+
def delete_item(table: str, item_id: int):
|
| 564 |
+
try:
|
| 565 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 566 |
+
except Exception as e:
|
| 567 |
+
raise e
|
| 568 |
+
|
| 569 |
+
if st.session_state.get("active_visit"):
|
| 570 |
+
st.subheader("🧾 Items in this visit")
|
| 571 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 572 |
+
if rows:
|
| 573 |
+
df = pd.DataFrame(rows)
|
| 574 |
+
st.dataframe(df, use_container_width=True)
|
| 575 |
+
with st.expander("🗑️ Delete an item (if mis-logged)"):
|
| 576 |
+
ids = [r["id"] for r in rows if "id" in r]
|
| 577 |
+
choice = st.selectbox("Choose item id", ids) if ids else None
|
| 578 |
+
st.markdown('<div class="cc-danger">', unsafe_allow_html=True)
|
| 579 |
+
if st.button("Delete selected", disabled=not bool(ids)):
|
| 580 |
+
if choice is None:
|
| 581 |
+
st.warning("Pick an id.")
|
| 582 |
+
else:
|
| 583 |
+
# try both tables safely
|
| 584 |
+
try:
|
| 585 |
+
delete_item("visit_items_p", int(choice))
|
| 586 |
+
except Exception:
|
| 587 |
+
delete_item("visit_items", int(choice))
|
| 588 |
+
st.success("Deleted.")
|
| 589 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 590 |
+
else:
|
| 591 |
+
st.caption("No items logged for this visit yet.")
|
| 592 |
+
|
| 593 |
+
# ------------------------ Analytics (light) ------------------------
|
| 594 |
+
st.subheader("📈 Today")
|
| 595 |
+
try:
|
| 596 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 597 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 598 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 599 |
+
visits = int(today["visits"]) if today and "visits" in today else 0
|
| 600 |
+
items = int(today["items"]) if today and "items" in today else 0
|
| 601 |
+
st.markdown(f"**Visits:** {visits} · **Items:** {items}")
|
| 602 |
+
except Exception:
|
| 603 |
+
st.caption("Analytics view unavailable yet.")
|
| 604 |
+
|
| 605 |
+
# ------------------------ Gentle reminder ------------------------
|
| 606 |
+
st.markdown(
|
| 607 |
+
"""<div class="cc-hint">
|
| 608 |
+
💡 When you’re done, please <b>End Visit</b> and <b>Sign out</b>.<br>
|
| 609 |
+
We’ll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 610 |
+
</div>""",
|
| 611 |
+
unsafe_allow_html=True
|
| 612 |
+
)
|
backups/pre_modern_deployment_20250923_202615/streamlit_app_modern.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_202615/test_app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Care Count App Testing Framework
|
| 4 |
+
Tests UI/UX changes and backend functionality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Dict, Any, List
|
| 13 |
+
import subprocess
|
| 14 |
+
import requests
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# Setup logging
|
| 18 |
+
logging.basicConfig(
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 21 |
+
handlers=[
|
| 22 |
+
logging.FileHandler('test_results.log'),
|
| 23 |
+
logging.StreamHandler()
|
| 24 |
+
]
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
class CareCountTester:
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.test_results = []
|
| 31 |
+
self.app_url = "http://localhost:8501"
|
| 32 |
+
self.backup_dir = Path("backups")
|
| 33 |
+
self.backup_dir.mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
def create_backup(self, description: str = "manual_backup"):
|
| 36 |
+
"""Create a timestamped backup of current app state"""
|
| 37 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 38 |
+
backup_name = f"{description}_{timestamp}"
|
| 39 |
+
|
| 40 |
+
# Backup main files
|
| 41 |
+
files_to_backup = [
|
| 42 |
+
"streamlit_app.py",
|
| 43 |
+
".streamlit/secrets.toml",
|
| 44 |
+
"requirements.txt"
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
backup_path = self.backup_dir / backup_name
|
| 48 |
+
backup_path.mkdir(exist_ok=True)
|
| 49 |
+
|
| 50 |
+
for file_path in files_to_backup:
|
| 51 |
+
if os.path.exists(file_path):
|
| 52 |
+
subprocess.run(["cp", file_path, str(backup_path / os.path.basename(file_path))])
|
| 53 |
+
|
| 54 |
+
logger.info(f"Backup created: {backup_name}")
|
| 55 |
+
return backup_name
|
| 56 |
+
|
| 57 |
+
def restore_backup(self, backup_name: str):
|
| 58 |
+
"""Restore from a backup"""
|
| 59 |
+
backup_path = self.backup_dir / backup_name
|
| 60 |
+
|
| 61 |
+
if not backup_path.exists():
|
| 62 |
+
logger.error(f"Backup {backup_name} not found")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
# Restore files
|
| 66 |
+
files_to_restore = [
|
| 67 |
+
"streamlit_app.py",
|
| 68 |
+
".streamlit/secrets.toml",
|
| 69 |
+
"requirements.txt"
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
for file_path in files_to_restore:
|
| 73 |
+
backup_file = backup_path / os.path.basename(file_path)
|
| 74 |
+
if backup_file.exists():
|
| 75 |
+
subprocess.run(["cp", str(backup_file), file_path])
|
| 76 |
+
|
| 77 |
+
logger.info(f"Restored from backup: {backup_name}")
|
| 78 |
+
return True
|
| 79 |
+
|
| 80 |
+
def test_app_startup(self) -> bool:
|
| 81 |
+
"""Test if the app starts without errors"""
|
| 82 |
+
try:
|
| 83 |
+
# Check if app is already running
|
| 84 |
+
response = requests.get(self.app_url, timeout=5)
|
| 85 |
+
if response.status_code == 200:
|
| 86 |
+
logger.info("✅ App is running and accessible")
|
| 87 |
+
return True
|
| 88 |
+
else:
|
| 89 |
+
logger.error(f"❌ App returned status code: {response.status_code}")
|
| 90 |
+
return False
|
| 91 |
+
except requests.exceptions.RequestException as e:
|
| 92 |
+
logger.error(f"❌ App startup test failed: {e}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
def test_ui_elements(self) -> Dict[str, bool]:
|
| 96 |
+
"""Test key UI elements are present"""
|
| 97 |
+
results = {}
|
| 98 |
+
try:
|
| 99 |
+
response = requests.get(self.app_url, timeout=10)
|
| 100 |
+
content = response.text.lower()
|
| 101 |
+
|
| 102 |
+
# Test for key UI elements
|
| 103 |
+
ui_tests = {
|
| 104 |
+
"title_present": "care count" in content,
|
| 105 |
+
"signin_form": "sign in" in content or "email" in content,
|
| 106 |
+
"camera_input": "camera" in content or "webcam" in content,
|
| 107 |
+
"file_upload": "upload" in content or "file" in content,
|
| 108 |
+
"visit_management": "visit" in content,
|
| 109 |
+
"item_logging": "item" in content,
|
| 110 |
+
"css_styling": "style" in content or "css" in content
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
for test_name, result in ui_tests.items():
|
| 114 |
+
results[test_name] = result
|
| 115 |
+
status = "✅" if result else "❌"
|
| 116 |
+
logger.info(f"{status} {test_name}: {result}")
|
| 117 |
+
|
| 118 |
+
return results
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"❌ UI elements test failed: {e}")
|
| 122 |
+
return {}
|
| 123 |
+
|
| 124 |
+
def test_responsive_design(self) -> bool:
|
| 125 |
+
"""Test if the app has responsive design elements"""
|
| 126 |
+
try:
|
| 127 |
+
response = requests.get(self.app_url, timeout=10)
|
| 128 |
+
content = response.text
|
| 129 |
+
|
| 130 |
+
# Check for responsive design indicators
|
| 131 |
+
responsive_indicators = [
|
| 132 |
+
"container" in content,
|
| 133 |
+
"column" in content,
|
| 134 |
+
"responsive" in content,
|
| 135 |
+
"mobile" in content
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
responsive_score = sum(responsive_indicators) / len(responsive_indicators)
|
| 139 |
+
is_responsive = responsive_score >= 0.5
|
| 140 |
+
|
| 141 |
+
logger.info(f"📱 Responsive design score: {responsive_score:.2f} ({'✅' if is_responsive else '❌'})")
|
| 142 |
+
return is_responsive
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"❌ Responsive design test failed: {e}")
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
def test_performance(self) -> Dict[str, float]:
|
| 149 |
+
"""Test app performance metrics"""
|
| 150 |
+
try:
|
| 151 |
+
start_time = time.time()
|
| 152 |
+
response = requests.get(self.app_url, timeout=30)
|
| 153 |
+
load_time = time.time() - start_time
|
| 154 |
+
|
| 155 |
+
results = {
|
| 156 |
+
"load_time": load_time,
|
| 157 |
+
"status_code": response.status_code,
|
| 158 |
+
"content_size": len(response.content)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
logger.info(f"⚡ Load time: {load_time:.2f}s")
|
| 162 |
+
logger.info(f"📊 Content size: {len(response.content)} bytes")
|
| 163 |
+
|
| 164 |
+
return results
|
| 165 |
+
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"❌ Performance test failed: {e}")
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
def run_all_tests(self) -> Dict[str, Any]:
|
| 171 |
+
"""Run all tests and return comprehensive results"""
|
| 172 |
+
logger.info("🧪 Starting comprehensive app testing...")
|
| 173 |
+
|
| 174 |
+
# Create backup before testing
|
| 175 |
+
backup_name = self.create_backup("pre_test_backup")
|
| 176 |
+
|
| 177 |
+
test_results = {
|
| 178 |
+
"timestamp": datetime.now().isoformat(),
|
| 179 |
+
"backup_created": backup_name,
|
| 180 |
+
"startup_test": self.test_app_startup(),
|
| 181 |
+
"ui_elements": self.test_ui_elements(),
|
| 182 |
+
"responsive_design": self.test_responsive_design(),
|
| 183 |
+
"performance": self.test_performance()
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
# Calculate overall score
|
| 187 |
+
ui_score = sum(test_results["ui_elements"].values()) / len(test_results["ui_elements"]) if test_results["ui_elements"] else 0
|
| 188 |
+
overall_score = (
|
| 189 |
+
(1 if test_results["startup_test"] else 0) +
|
| 190 |
+
ui_score +
|
| 191 |
+
(1 if test_results["responsive_design"] else 0)
|
| 192 |
+
) / 3
|
| 193 |
+
|
| 194 |
+
test_results["overall_score"] = overall_score
|
| 195 |
+
test_results["status"] = "PASS" if overall_score >= 0.8 else "FAIL"
|
| 196 |
+
|
| 197 |
+
logger.info(f"🎯 Overall test score: {overall_score:.2f} ({test_results['status']})")
|
| 198 |
+
|
| 199 |
+
return test_results
|
| 200 |
+
|
| 201 |
+
def main():
|
| 202 |
+
"""Main testing function"""
|
| 203 |
+
tester = CareCountTester()
|
| 204 |
+
|
| 205 |
+
if len(sys.argv) > 1:
|
| 206 |
+
command = sys.argv[1]
|
| 207 |
+
|
| 208 |
+
if command == "backup":
|
| 209 |
+
description = sys.argv[2] if len(sys.argv) > 2 else "manual_backup"
|
| 210 |
+
tester.create_backup(description)
|
| 211 |
+
elif command == "restore":
|
| 212 |
+
if len(sys.argv) > 2:
|
| 213 |
+
tester.restore_backup(sys.argv[2])
|
| 214 |
+
else:
|
| 215 |
+
print("Usage: python test_app.py restore <backup_name>")
|
| 216 |
+
elif command == "test":
|
| 217 |
+
results = tester.run_all_tests()
|
| 218 |
+
print(f"\n📋 Test Results Summary:")
|
| 219 |
+
print(f"Status: {results['status']}")
|
| 220 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 221 |
+
print(f"Backup: {results['backup_created']}")
|
| 222 |
+
else:
|
| 223 |
+
print("Available commands: backup, restore, test")
|
| 224 |
+
else:
|
| 225 |
+
# Run all tests by default
|
| 226 |
+
results = tester.run_all_tests()
|
| 227 |
+
print(f"\n📋 Test Results Summary:")
|
| 228 |
+
print(f"Status: {results['status']}")
|
| 229 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 230 |
+
print(f"Backup: {results['backup_created']}")
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
main()
|
backups/pre_modern_deployment_20250923_202615/ui_improvements.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Care Count UI/UX Improvements Module
|
| 3 |
+
Industry-standard UI components and styling
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
import base64
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
class ModernUIComponents:
|
| 12 |
+
"""Modern UI components for Care Count app"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def get_modern_css() -> str:
|
| 16 |
+
"""Return modern, industry-standard CSS"""
|
| 17 |
+
return """
|
| 18 |
+
<style>
|
| 19 |
+
/* Modern Design System */
|
| 20 |
+
:root {
|
| 21 |
+
/* Color Palette - Laurier Theme Enhanced */
|
| 22 |
+
--primary-purple: #6d28d9;
|
| 23 |
+
--primary-gold: #fde047;
|
| 24 |
+
--primary-dark: #0b1420;
|
| 25 |
+
--secondary-dark: #0f1a2a;
|
| 26 |
+
--accent-blue: #3b82f6;
|
| 27 |
+
--accent-green: #10b981;
|
| 28 |
+
--accent-red: #ef4444;
|
| 29 |
+
--accent-orange: #f59e0b;
|
| 30 |
+
|
| 31 |
+
/* Neutral Colors */
|
| 32 |
+
--gray-50: #f9fafb;
|
| 33 |
+
--gray-100: #f3f4f6;
|
| 34 |
+
--gray-200: #e5e7eb;
|
| 35 |
+
--gray-300: #d1d5db;
|
| 36 |
+
--gray-400: #9ca3af;
|
| 37 |
+
--gray-500: #6b7280;
|
| 38 |
+
--gray-600: #4b5563;
|
| 39 |
+
--gray-700: #374151;
|
| 40 |
+
--gray-800: #1f2937;
|
| 41 |
+
--gray-900: #111827;
|
| 42 |
+
|
| 43 |
+
/* Typography */
|
| 44 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 45 |
+
--font-size-xs: 0.75rem;
|
| 46 |
+
--font-size-sm: 0.875rem;
|
| 47 |
+
--font-size-base: 1rem;
|
| 48 |
+
--font-size-lg: 1.125rem;
|
| 49 |
+
--font-size-xl: 1.25rem;
|
| 50 |
+
--font-size-2xl: 1.5rem;
|
| 51 |
+
--font-size-3xl: 1.875rem;
|
| 52 |
+
|
| 53 |
+
/* Spacing */
|
| 54 |
+
--space-1: 0.25rem;
|
| 55 |
+
--space-2: 0.5rem;
|
| 56 |
+
--space-3: 0.75rem;
|
| 57 |
+
--space-4: 1rem;
|
| 58 |
+
--space-5: 1.25rem;
|
| 59 |
+
--space-6: 1.5rem;
|
| 60 |
+
--space-8: 2rem;
|
| 61 |
+
--space-10: 2.5rem;
|
| 62 |
+
--space-12: 3rem;
|
| 63 |
+
|
| 64 |
+
/* Border Radius */
|
| 65 |
+
--radius-sm: 0.375rem;
|
| 66 |
+
--radius-md: 0.5rem;
|
| 67 |
+
--radius-lg: 0.75rem;
|
| 68 |
+
--radius-xl: 1rem;
|
| 69 |
+
--radius-2xl: 1.5rem;
|
| 70 |
+
|
| 71 |
+
/* Shadows */
|
| 72 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 73 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 74 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 75 |
+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Global Styles */
|
| 79 |
+
* {
|
| 80 |
+
box-sizing: border-box;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
body {
|
| 84 |
+
font-family: var(--font-family);
|
| 85 |
+
background: var(--primary-dark);
|
| 86 |
+
color: var(--gray-100);
|
| 87 |
+
line-height: 1.6;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Streamlit Overrides */
|
| 91 |
+
.main .block-container {
|
| 92 |
+
padding: var(--space-6) var(--space-4);
|
| 93 |
+
max-width: 1200px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.stApp {
|
| 97 |
+
background: var(--primary-dark);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Typography */
|
| 101 |
+
h1, h2, h3, h4, h5, h6 {
|
| 102 |
+
font-weight: 700;
|
| 103 |
+
letter-spacing: -0.025em;
|
| 104 |
+
color: var(--gray-100);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
h1 {
|
| 108 |
+
font-size: var(--font-size-3xl);
|
| 109 |
+
margin-bottom: var(--space-6);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
h2 {
|
| 113 |
+
font-size: var(--font-size-2xl);
|
| 114 |
+
margin-bottom: var(--space-4);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
h3 {
|
| 118 |
+
font-size: var(--font-size-xl);
|
| 119 |
+
margin-bottom: var(--space-3);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* Modern Cards */
|
| 123 |
+
.modern-card {
|
| 124 |
+
background: var(--secondary-dark);
|
| 125 |
+
border: 1px solid var(--gray-700);
|
| 126 |
+
border-radius: var(--radius-xl);
|
| 127 |
+
padding: var(--space-6);
|
| 128 |
+
margin-bottom: var(--space-4);
|
| 129 |
+
box-shadow: var(--shadow-lg);
|
| 130 |
+
transition: all 0.2s ease;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.modern-card:hover {
|
| 134 |
+
border-color: var(--primary-purple);
|
| 135 |
+
box-shadow: var(--shadow-xl);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* Status Cards */
|
| 139 |
+
.status-grid {
|
| 140 |
+
display: grid;
|
| 141 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 142 |
+
gap: var(--space-4);
|
| 143 |
+
margin-bottom: var(--space-6);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.status-card {
|
| 147 |
+
background: var(--secondary-dark);
|
| 148 |
+
border: 1px solid var(--gray-700);
|
| 149 |
+
border-radius: var(--radius-lg);
|
| 150 |
+
padding: var(--space-4);
|
| 151 |
+
text-align: center;
|
| 152 |
+
transition: all 0.2s ease;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.status-card:hover {
|
| 156 |
+
transform: translateY(-2px);
|
| 157 |
+
box-shadow: var(--shadow-lg);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.status-card h4 {
|
| 161 |
+
margin: 0 0 var(--space-2) 0;
|
| 162 |
+
font-size: var(--font-size-sm);
|
| 163 |
+
color: var(--gray-400);
|
| 164 |
+
text-transform: uppercase;
|
| 165 |
+
letter-spacing: 0.05em;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.status-card .value {
|
| 169 |
+
font-size: var(--font-size-2xl);
|
| 170 |
+
font-weight: 800;
|
| 171 |
+
color: var(--primary-gold);
|
| 172 |
+
margin: 0;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.status-card .subtitle {
|
| 176 |
+
font-size: var(--font-size-xs);
|
| 177 |
+
color: var(--gray-500);
|
| 178 |
+
margin: var(--space-1) 0 0 0;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* Modern Buttons */
|
| 182 |
+
.modern-btn {
|
| 183 |
+
background: var(--primary-purple);
|
| 184 |
+
color: white;
|
| 185 |
+
border: none;
|
| 186 |
+
border-radius: var(--radius-md);
|
| 187 |
+
padding: var(--space-3) var(--space-6);
|
| 188 |
+
font-weight: 600;
|
| 189 |
+
font-size: var(--font-size-sm);
|
| 190 |
+
cursor: pointer;
|
| 191 |
+
transition: all 0.2s ease;
|
| 192 |
+
box-shadow: var(--shadow-sm);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.modern-btn:hover {
|
| 196 |
+
background: #5b21b6;
|
| 197 |
+
transform: translateY(-1px);
|
| 198 |
+
box-shadow: var(--shadow-md);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.modern-btn:active {
|
| 202 |
+
transform: translateY(0);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.modern-btn-secondary {
|
| 206 |
+
background: var(--gray-700);
|
| 207 |
+
color: var(--gray-100);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.modern-btn-secondary:hover {
|
| 211 |
+
background: var(--gray-600);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.modern-btn-success {
|
| 215 |
+
background: var(--accent-green);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.modern-btn-success:hover {
|
| 219 |
+
background: #059669;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.modern-btn-danger {
|
| 223 |
+
background: var(--accent-red);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.modern-btn-danger:hover {
|
| 227 |
+
background: #dc2626;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* Form Elements */
|
| 231 |
+
.modern-input {
|
| 232 |
+
background: var(--secondary-dark);
|
| 233 |
+
border: 1px solid var(--gray-600);
|
| 234 |
+
border-radius: var(--radius-md);
|
| 235 |
+
padding: var(--space-3);
|
| 236 |
+
color: var(--gray-100);
|
| 237 |
+
font-size: var(--font-size-sm);
|
| 238 |
+
transition: all 0.2s ease;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.modern-input:focus {
|
| 242 |
+
outline: none;
|
| 243 |
+
border-color: var(--primary-purple);
|
| 244 |
+
box-shadow: 0 0 0 3px rgb(109 40 217 / 0.1);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* Hero Section */
|
| 248 |
+
.hero-section {
|
| 249 |
+
background: linear-gradient(135deg, var(--primary-purple) 0%, var(--accent-blue) 100%);
|
| 250 |
+
border-radius: var(--radius-2xl);
|
| 251 |
+
padding: var(--space-8);
|
| 252 |
+
margin-bottom: var(--space-8);
|
| 253 |
+
text-align: center;
|
| 254 |
+
color: white;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.hero-section h1 {
|
| 258 |
+
color: white;
|
| 259 |
+
margin-bottom: var(--space-4);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.hero-section p {
|
| 263 |
+
font-size: var(--font-size-lg);
|
| 264 |
+
opacity: 0.9;
|
| 265 |
+
margin: 0;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* Progress Indicators */
|
| 269 |
+
.progress-bar {
|
| 270 |
+
background: var(--gray-700);
|
| 271 |
+
border-radius: var(--radius-lg);
|
| 272 |
+
height: 8px;
|
| 273 |
+
overflow: hidden;
|
| 274 |
+
margin: var(--space-2) 0;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.progress-fill {
|
| 278 |
+
background: linear-gradient(90deg, var(--primary-purple), var(--primary-gold));
|
| 279 |
+
height: 100%;
|
| 280 |
+
transition: width 0.3s ease;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/* Badges */
|
| 284 |
+
.badge {
|
| 285 |
+
display: inline-block;
|
| 286 |
+
padding: var(--space-1) var(--space-2);
|
| 287 |
+
border-radius: var(--radius-sm);
|
| 288 |
+
font-size: var(--font-size-xs);
|
| 289 |
+
font-weight: 600;
|
| 290 |
+
text-transform: uppercase;
|
| 291 |
+
letter-spacing: 0.05em;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.badge-success {
|
| 295 |
+
background: var(--accent-green);
|
| 296 |
+
color: white;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.badge-warning {
|
| 300 |
+
background: var(--accent-orange);
|
| 301 |
+
color: white;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.badge-danger {
|
| 305 |
+
background: var(--accent-red);
|
| 306 |
+
color: white;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.badge-info {
|
| 310 |
+
background: var(--accent-blue);
|
| 311 |
+
color: white;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/* Responsive Design */
|
| 315 |
+
@media (max-width: 768px) {
|
| 316 |
+
.main .block-container {
|
| 317 |
+
padding: var(--space-4) var(--space-2);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.status-grid {
|
| 321 |
+
grid-template-columns: 1fr;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.hero-section {
|
| 325 |
+
padding: var(--space-6);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
h1 {
|
| 329 |
+
font-size: var(--font-size-2xl);
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* Loading States */
|
| 334 |
+
.loading {
|
| 335 |
+
display: inline-block;
|
| 336 |
+
width: 20px;
|
| 337 |
+
height: 20px;
|
| 338 |
+
border: 3px solid var(--gray-600);
|
| 339 |
+
border-radius: 50%;
|
| 340 |
+
border-top-color: var(--primary-purple);
|
| 341 |
+
animation: spin 1s ease-in-out infinite;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
@keyframes spin {
|
| 345 |
+
to { transform: rotate(360deg); }
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
/* Animations */
|
| 349 |
+
.fade-in {
|
| 350 |
+
animation: fadeIn 0.5s ease-in;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
@keyframes fadeIn {
|
| 354 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 355 |
+
to { opacity: 1; transform: translateY(0); }
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.slide-in {
|
| 359 |
+
animation: slideIn 0.3s ease-out;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
@keyframes slideIn {
|
| 363 |
+
from { transform: translateX(-100%); }
|
| 364 |
+
to { transform: translateX(0); }
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
/* Accessibility */
|
| 368 |
+
.sr-only {
|
| 369 |
+
position: absolute;
|
| 370 |
+
width: 1px;
|
| 371 |
+
height: 1px;
|
| 372 |
+
padding: 0;
|
| 373 |
+
margin: -1px;
|
| 374 |
+
overflow: hidden;
|
| 375 |
+
clip: rect(0, 0, 0, 0);
|
| 376 |
+
white-space: nowrap;
|
| 377 |
+
border: 0;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/* Focus states for accessibility */
|
| 381 |
+
button:focus,
|
| 382 |
+
input:focus,
|
| 383 |
+
select:focus,
|
| 384 |
+
textarea:focus {
|
| 385 |
+
outline: 2px solid var(--primary-purple);
|
| 386 |
+
outline-offset: 2px;
|
| 387 |
+
}
|
| 388 |
+
</style>
|
| 389 |
+
"""
|
| 390 |
+
|
| 391 |
+
@staticmethod
|
| 392 |
+
def create_hero_section(title: str, subtitle: str, user_email: str = None) -> str:
|
| 393 |
+
"""Create a modern hero section"""
|
| 394 |
+
if user_email:
|
| 395 |
+
return f"""
|
| 396 |
+
<div class="hero-section fade-in">
|
| 397 |
+
<h1>💜💛 {title}</h1>
|
| 398 |
+
<p>{subtitle}</p>
|
| 399 |
+
<div style="margin-top: var(--space-4); padding: var(--space-4); background: rgba(255,255,255,0.1); border-radius: var(--radius-lg);">
|
| 400 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8;">Welcome,</div>
|
| 401 |
+
<div style="font-weight: 800; font-size: var(--font-size-lg); margin: var(--space-1) 0;">{user_email}</div>
|
| 402 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8;">Thank you for showing up for the community today. 💜💛</div>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
"""
|
| 406 |
+
else:
|
| 407 |
+
return f"""
|
| 408 |
+
<div class="hero-section fade-in">
|
| 409 |
+
<h1>💜💛 {title}</h1>
|
| 410 |
+
<p>{subtitle}</p>
|
| 411 |
+
</div>
|
| 412 |
+
"""
|
| 413 |
+
|
| 414 |
+
@staticmethod
|
| 415 |
+
def create_status_cards(data: Dict[str, Any]) -> str:
|
| 416 |
+
"""Create modern status cards"""
|
| 417 |
+
cards_html = '<div class="status-grid">'
|
| 418 |
+
|
| 419 |
+
for key, value in data.items():
|
| 420 |
+
if key == "shift_active":
|
| 421 |
+
cards_html += f"""
|
| 422 |
+
<div class="status-card slide-in">
|
| 423 |
+
<h4>Shift Active</h4>
|
| 424 |
+
<div class="value">{value}</div>
|
| 425 |
+
<div class="subtitle">since you signed in</div>
|
| 426 |
+
</div>
|
| 427 |
+
"""
|
| 428 |
+
elif key == "items_today":
|
| 429 |
+
cards_html += f"""
|
| 430 |
+
<div class="status-card slide-in">
|
| 431 |
+
<h4>Items Logged Today</h4>
|
| 432 |
+
<div class="value">{value}</div>
|
| 433 |
+
<div class="subtitle">items processed</div>
|
| 434 |
+
</div>
|
| 435 |
+
"""
|
| 436 |
+
elif key == "lifetime_hours":
|
| 437 |
+
cards_html += f"""
|
| 438 |
+
<div class="status-card slide-in">
|
| 439 |
+
<h4>Lifetime Hours</h4>
|
| 440 |
+
<div class="value">{value}</div>
|
| 441 |
+
<div class="subtitle">volunteer hours</div>
|
| 442 |
+
</div>
|
| 443 |
+
"""
|
| 444 |
+
|
| 445 |
+
cards_html += '</div>'
|
| 446 |
+
return cards_html
|
| 447 |
+
|
| 448 |
+
@staticmethod
|
| 449 |
+
def create_modern_form_section(title: str, description: str = None) -> str:
|
| 450 |
+
"""Create a modern form section header"""
|
| 451 |
+
desc_html = f'<p style="color: var(--gray-400); margin-bottom: var(--space-4);">{description}</p>' if description else ''
|
| 452 |
+
return f"""
|
| 453 |
+
<div class="modern-card fade-in">
|
| 454 |
+
<h3>{title}</h3>
|
| 455 |
+
{desc_html}
|
| 456 |
+
"""
|
| 457 |
+
|
| 458 |
+
@staticmethod
|
| 459 |
+
def create_progress_indicator(current: int, total: int, label: str) -> str:
|
| 460 |
+
"""Create a modern progress indicator"""
|
| 461 |
+
percentage = (current / total * 100) if total > 0 else 0
|
| 462 |
+
return f"""
|
| 463 |
+
<div style="margin: var(--space-4) 0;">
|
| 464 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
|
| 465 |
+
<span style="font-weight: 600; color: var(--gray-300);">{label}</span>
|
| 466 |
+
<span style="font-weight: 700; color: var(--primary-gold);">{current}/{total}</span>
|
| 467 |
+
</div>
|
| 468 |
+
<div class="progress-bar">
|
| 469 |
+
<div class="progress-fill" style="width: {percentage}%;"></div>
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
"""
|
| 473 |
+
|
| 474 |
+
@staticmethod
|
| 475 |
+
def create_badge(text: str, variant: str = "info") -> str:
|
| 476 |
+
"""Create a modern badge"""
|
| 477 |
+
return f'<span class="badge badge-{variant}">{text}</span>'
|
| 478 |
+
|
| 479 |
+
@staticmethod
|
| 480 |
+
def create_loading_spinner() -> str:
|
| 481 |
+
"""Create a loading spinner"""
|
| 482 |
+
return '<div class="loading"></div>'
|
| 483 |
+
|
| 484 |
+
def apply_modern_ui():
|
| 485 |
+
"""Apply modern UI styling to the Streamlit app"""
|
| 486 |
+
st.markdown(ModernUIComponents.get_modern_css(), unsafe_allow_html=True)
|
| 487 |
+
|
| 488 |
+
def create_modern_layout():
|
| 489 |
+
"""Create a modern layout structure"""
|
| 490 |
+
return {
|
| 491 |
+
"container_style": "max-width: 1200px; margin: 0 auto;",
|
| 492 |
+
"sidebar_style": "background: var(--secondary-dark); border-right: 1px solid var(--gray-700);",
|
| 493 |
+
"main_style": "background: var(--primary-dark); padding: var(--space-6);"
|
| 494 |
+
}
|
backups/pre_modern_deployment_20250923_203628/backup_metadata.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backup_name": "pre_modern_deployment_20250923_203628",
|
| 3 |
+
"timestamp": "20250923_203628",
|
| 4 |
+
"description": "pre_modern_deployment",
|
| 5 |
+
"files_backed_up": [
|
| 6 |
+
"streamlit_app.py",
|
| 7 |
+
"streamlit_app_modern.py",
|
| 8 |
+
".streamlit/secrets.toml",
|
| 9 |
+
"requirements.txt",
|
| 10 |
+
"ui_improvements.py",
|
| 11 |
+
"test_app.py"
|
| 12 |
+
],
|
| 13 |
+
"git_status": {
|
| 14 |
+
"modified_files": [
|
| 15 |
+
"M requirements.txt",
|
| 16 |
+
" M streamlit_app.py",
|
| 17 |
+
"?? .streamlit/",
|
| 18 |
+
"?? .venv/",
|
| 19 |
+
"?? DEPLOYMENT_QUICK_REFERENCE.md",
|
| 20 |
+
"?? UI_IMPROVEMENTS_GUIDE.md",
|
| 21 |
+
"?? __pycache__/",
|
| 22 |
+
"?? backups/",
|
| 23 |
+
"?? care_count.log",
|
| 24 |
+
"?? deploy_modern.py",
|
| 25 |
+
"?? deployment.log",
|
| 26 |
+
"?? streamlit_app_backup_20250923_195853.py",
|
| 27 |
+
"?? streamlit_app_modern.py",
|
| 28 |
+
"?? test_app.py",
|
| 29 |
+
"?? test_results.log",
|
| 30 |
+
"?? ui_improvements.py"
|
| 31 |
+
],
|
| 32 |
+
"return_code": 0
|
| 33 |
+
}
|
| 34 |
+
}
|
backups/pre_modern_deployment_20250923_203628/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_modern_deployment_20250923_203628/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_modern_deployment_20250923_203628/streamlit_app.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
), unsafe_allow_html=True)
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
), unsafe_allow_html=True)
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_203628/streamlit_app_modern.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_203628/test_app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Care Count App Testing Framework
|
| 4 |
+
Tests UI/UX changes and backend functionality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Dict, Any, List
|
| 13 |
+
import subprocess
|
| 14 |
+
import requests
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# Setup logging
|
| 18 |
+
logging.basicConfig(
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 21 |
+
handlers=[
|
| 22 |
+
logging.FileHandler('test_results.log'),
|
| 23 |
+
logging.StreamHandler()
|
| 24 |
+
]
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
class CareCountTester:
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.test_results = []
|
| 31 |
+
self.app_url = "http://localhost:8501"
|
| 32 |
+
self.backup_dir = Path("backups")
|
| 33 |
+
self.backup_dir.mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
def create_backup(self, description: str = "manual_backup"):
|
| 36 |
+
"""Create a timestamped backup of current app state"""
|
| 37 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 38 |
+
backup_name = f"{description}_{timestamp}"
|
| 39 |
+
|
| 40 |
+
# Backup main files
|
| 41 |
+
files_to_backup = [
|
| 42 |
+
"streamlit_app.py",
|
| 43 |
+
".streamlit/secrets.toml",
|
| 44 |
+
"requirements.txt"
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
backup_path = self.backup_dir / backup_name
|
| 48 |
+
backup_path.mkdir(exist_ok=True)
|
| 49 |
+
|
| 50 |
+
for file_path in files_to_backup:
|
| 51 |
+
if os.path.exists(file_path):
|
| 52 |
+
subprocess.run(["cp", file_path, str(backup_path / os.path.basename(file_path))])
|
| 53 |
+
|
| 54 |
+
logger.info(f"Backup created: {backup_name}")
|
| 55 |
+
return backup_name
|
| 56 |
+
|
| 57 |
+
def restore_backup(self, backup_name: str):
|
| 58 |
+
"""Restore from a backup"""
|
| 59 |
+
backup_path = self.backup_dir / backup_name
|
| 60 |
+
|
| 61 |
+
if not backup_path.exists():
|
| 62 |
+
logger.error(f"Backup {backup_name} not found")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
# Restore files
|
| 66 |
+
files_to_restore = [
|
| 67 |
+
"streamlit_app.py",
|
| 68 |
+
".streamlit/secrets.toml",
|
| 69 |
+
"requirements.txt"
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
for file_path in files_to_restore:
|
| 73 |
+
backup_file = backup_path / os.path.basename(file_path)
|
| 74 |
+
if backup_file.exists():
|
| 75 |
+
subprocess.run(["cp", str(backup_file), file_path])
|
| 76 |
+
|
| 77 |
+
logger.info(f"Restored from backup: {backup_name}")
|
| 78 |
+
return True
|
| 79 |
+
|
| 80 |
+
def test_app_startup(self) -> bool:
|
| 81 |
+
"""Test if the app starts without errors"""
|
| 82 |
+
try:
|
| 83 |
+
# Check if app is already running
|
| 84 |
+
response = requests.get(self.app_url, timeout=5)
|
| 85 |
+
if response.status_code == 200:
|
| 86 |
+
logger.info("✅ App is running and accessible")
|
| 87 |
+
return True
|
| 88 |
+
else:
|
| 89 |
+
logger.error(f"❌ App returned status code: {response.status_code}")
|
| 90 |
+
return False
|
| 91 |
+
except requests.exceptions.RequestException as e:
|
| 92 |
+
logger.error(f"❌ App startup test failed: {e}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
def test_ui_elements(self) -> Dict[str, bool]:
|
| 96 |
+
"""Test key UI elements are present"""
|
| 97 |
+
results = {}
|
| 98 |
+
try:
|
| 99 |
+
response = requests.get(self.app_url, timeout=10)
|
| 100 |
+
content = response.text.lower()
|
| 101 |
+
|
| 102 |
+
# Test for key UI elements
|
| 103 |
+
ui_tests = {
|
| 104 |
+
"title_present": "care count" in content,
|
| 105 |
+
"signin_form": "sign in" in content or "email" in content,
|
| 106 |
+
"camera_input": "camera" in content or "webcam" in content,
|
| 107 |
+
"file_upload": "upload" in content or "file" in content,
|
| 108 |
+
"visit_management": "visit" in content,
|
| 109 |
+
"item_logging": "item" in content,
|
| 110 |
+
"css_styling": "style" in content or "css" in content
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
for test_name, result in ui_tests.items():
|
| 114 |
+
results[test_name] = result
|
| 115 |
+
status = "✅" if result else "❌"
|
| 116 |
+
logger.info(f"{status} {test_name}: {result}")
|
| 117 |
+
|
| 118 |
+
return results
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"❌ UI elements test failed: {e}")
|
| 122 |
+
return {}
|
| 123 |
+
|
| 124 |
+
def test_responsive_design(self) -> bool:
|
| 125 |
+
"""Test if the app has responsive design elements"""
|
| 126 |
+
try:
|
| 127 |
+
response = requests.get(self.app_url, timeout=10)
|
| 128 |
+
content = response.text
|
| 129 |
+
|
| 130 |
+
# Check for responsive design indicators
|
| 131 |
+
responsive_indicators = [
|
| 132 |
+
"container" in content,
|
| 133 |
+
"column" in content,
|
| 134 |
+
"responsive" in content,
|
| 135 |
+
"mobile" in content
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
responsive_score = sum(responsive_indicators) / len(responsive_indicators)
|
| 139 |
+
is_responsive = responsive_score >= 0.5
|
| 140 |
+
|
| 141 |
+
logger.info(f"📱 Responsive design score: {responsive_score:.2f} ({'✅' if is_responsive else '❌'})")
|
| 142 |
+
return is_responsive
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"❌ Responsive design test failed: {e}")
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
def test_performance(self) -> Dict[str, float]:
|
| 149 |
+
"""Test app performance metrics"""
|
| 150 |
+
try:
|
| 151 |
+
start_time = time.time()
|
| 152 |
+
response = requests.get(self.app_url, timeout=30)
|
| 153 |
+
load_time = time.time() - start_time
|
| 154 |
+
|
| 155 |
+
results = {
|
| 156 |
+
"load_time": load_time,
|
| 157 |
+
"status_code": response.status_code,
|
| 158 |
+
"content_size": len(response.content)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
logger.info(f"⚡ Load time: {load_time:.2f}s")
|
| 162 |
+
logger.info(f"📊 Content size: {len(response.content)} bytes")
|
| 163 |
+
|
| 164 |
+
return results
|
| 165 |
+
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"❌ Performance test failed: {e}")
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
def run_all_tests(self) -> Dict[str, Any]:
|
| 171 |
+
"""Run all tests and return comprehensive results"""
|
| 172 |
+
logger.info("🧪 Starting comprehensive app testing...")
|
| 173 |
+
|
| 174 |
+
# Create backup before testing
|
| 175 |
+
backup_name = self.create_backup("pre_test_backup")
|
| 176 |
+
|
| 177 |
+
test_results = {
|
| 178 |
+
"timestamp": datetime.now().isoformat(),
|
| 179 |
+
"backup_created": backup_name,
|
| 180 |
+
"startup_test": self.test_app_startup(),
|
| 181 |
+
"ui_elements": self.test_ui_elements(),
|
| 182 |
+
"responsive_design": self.test_responsive_design(),
|
| 183 |
+
"performance": self.test_performance()
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
# Calculate overall score
|
| 187 |
+
ui_score = sum(test_results["ui_elements"].values()) / len(test_results["ui_elements"]) if test_results["ui_elements"] else 0
|
| 188 |
+
overall_score = (
|
| 189 |
+
(1 if test_results["startup_test"] else 0) +
|
| 190 |
+
ui_score +
|
| 191 |
+
(1 if test_results["responsive_design"] else 0)
|
| 192 |
+
) / 3
|
| 193 |
+
|
| 194 |
+
test_results["overall_score"] = overall_score
|
| 195 |
+
test_results["status"] = "PASS" if overall_score >= 0.8 else "FAIL"
|
| 196 |
+
|
| 197 |
+
logger.info(f"🎯 Overall test score: {overall_score:.2f} ({test_results['status']})")
|
| 198 |
+
|
| 199 |
+
return test_results
|
| 200 |
+
|
| 201 |
+
def main():
|
| 202 |
+
"""Main testing function"""
|
| 203 |
+
tester = CareCountTester()
|
| 204 |
+
|
| 205 |
+
if len(sys.argv) > 1:
|
| 206 |
+
command = sys.argv[1]
|
| 207 |
+
|
| 208 |
+
if command == "backup":
|
| 209 |
+
description = sys.argv[2] if len(sys.argv) > 2 else "manual_backup"
|
| 210 |
+
tester.create_backup(description)
|
| 211 |
+
elif command == "restore":
|
| 212 |
+
if len(sys.argv) > 2:
|
| 213 |
+
tester.restore_backup(sys.argv[2])
|
| 214 |
+
else:
|
| 215 |
+
print("Usage: python test_app.py restore <backup_name>")
|
| 216 |
+
elif command == "test":
|
| 217 |
+
results = tester.run_all_tests()
|
| 218 |
+
print(f"\n📋 Test Results Summary:")
|
| 219 |
+
print(f"Status: {results['status']}")
|
| 220 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 221 |
+
print(f"Backup: {results['backup_created']}")
|
| 222 |
+
else:
|
| 223 |
+
print("Available commands: backup, restore, test")
|
| 224 |
+
else:
|
| 225 |
+
# Run all tests by default
|
| 226 |
+
results = tester.run_all_tests()
|
| 227 |
+
print(f"\n📋 Test Results Summary:")
|
| 228 |
+
print(f"Status: {results['status']}")
|
| 229 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 230 |
+
print(f"Backup: {results['backup_created']}")
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
main()
|
backups/pre_modern_deployment_20250923_203628/ui_improvements.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Care Count UI/UX Improvements Module
|
| 3 |
+
Industry-standard UI components and styling
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
import base64
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
class ModernUIComponents:
|
| 12 |
+
"""Modern UI components for Care Count app"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def get_modern_css() -> str:
|
| 16 |
+
"""Return modern, industry-standard CSS"""
|
| 17 |
+
return """
|
| 18 |
+
<style>
|
| 19 |
+
/* Modern Design System - Laurier University Inspired */
|
| 20 |
+
:root {
|
| 21 |
+
/* Color Palette - Laurier University Theme */
|
| 22 |
+
--primary-purple: #6b46c1; /* Laurier purple - more vibrant */
|
| 23 |
+
--primary-gold: #fbbf24; /* Laurier gold - warmer tone */
|
| 24 |
+
--primary-dark: #1e1b4b; /* Deep purple background */
|
| 25 |
+
--secondary-dark: #312e81; /* Slightly lighter purple */
|
| 26 |
+
--accent-blue: #3b82f6;
|
| 27 |
+
--accent-green: #10b981;
|
| 28 |
+
--accent-red: #ef4444;
|
| 29 |
+
--accent-orange: #f59e0b;
|
| 30 |
+
|
| 31 |
+
/* Neutral Colors */
|
| 32 |
+
--gray-50: #f9fafb;
|
| 33 |
+
--gray-100: #f3f4f6;
|
| 34 |
+
--gray-200: #e5e7eb;
|
| 35 |
+
--gray-300: #d1d5db;
|
| 36 |
+
--gray-400: #9ca3af;
|
| 37 |
+
--gray-500: #6b7280;
|
| 38 |
+
--gray-600: #4b5563;
|
| 39 |
+
--gray-700: #374151;
|
| 40 |
+
--gray-800: #1f2937;
|
| 41 |
+
--gray-900: #111827;
|
| 42 |
+
|
| 43 |
+
/* Typography */
|
| 44 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 45 |
+
--font-size-xs: 0.75rem;
|
| 46 |
+
--font-size-sm: 0.875rem;
|
| 47 |
+
--font-size-base: 1rem;
|
| 48 |
+
--font-size-lg: 1.125rem;
|
| 49 |
+
--font-size-xl: 1.25rem;
|
| 50 |
+
--font-size-2xl: 1.5rem;
|
| 51 |
+
--font-size-3xl: 1.875rem;
|
| 52 |
+
|
| 53 |
+
/* Spacing */
|
| 54 |
+
--space-1: 0.25rem;
|
| 55 |
+
--space-2: 0.5rem;
|
| 56 |
+
--space-3: 0.75rem;
|
| 57 |
+
--space-4: 1rem;
|
| 58 |
+
--space-5: 1.25rem;
|
| 59 |
+
--space-6: 1.5rem;
|
| 60 |
+
--space-8: 2rem;
|
| 61 |
+
--space-10: 2.5rem;
|
| 62 |
+
--space-12: 3rem;
|
| 63 |
+
|
| 64 |
+
/* Border Radius */
|
| 65 |
+
--radius-sm: 0.375rem;
|
| 66 |
+
--radius-md: 0.5rem;
|
| 67 |
+
--radius-lg: 0.75rem;
|
| 68 |
+
--radius-xl: 1rem;
|
| 69 |
+
--radius-2xl: 1.5rem;
|
| 70 |
+
|
| 71 |
+
/* Shadows */
|
| 72 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 73 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 74 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 75 |
+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Global Styles */
|
| 79 |
+
* {
|
| 80 |
+
box-sizing: border-box;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
body {
|
| 84 |
+
font-family: var(--font-family);
|
| 85 |
+
background: var(--gray-50);
|
| 86 |
+
color: var(--gray-800);
|
| 87 |
+
line-height: 1.6;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Streamlit Overrides */
|
| 91 |
+
.main .block-container {
|
| 92 |
+
padding: var(--space-6) var(--space-4);
|
| 93 |
+
max-width: 1200px;
|
| 94 |
+
background: var(--gray-50);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.stApp {
|
| 98 |
+
background: var(--gray-50);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Typography */
|
| 102 |
+
h1, h2, h3, h4, h5, h6 {
|
| 103 |
+
font-weight: 700;
|
| 104 |
+
letter-spacing: -0.025em;
|
| 105 |
+
color: var(--gray-800);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
h1 {
|
| 109 |
+
font-size: var(--font-size-3xl);
|
| 110 |
+
margin-bottom: var(--space-6);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
h2 {
|
| 114 |
+
font-size: var(--font-size-2xl);
|
| 115 |
+
margin-bottom: var(--space-4);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
h3 {
|
| 119 |
+
font-size: var(--font-size-xl);
|
| 120 |
+
margin-bottom: var(--space-3);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Modern Cards - Laurier Style */
|
| 124 |
+
.modern-card {
|
| 125 |
+
background: white;
|
| 126 |
+
border: 1px solid var(--gray-200);
|
| 127 |
+
border-radius: var(--radius-xl);
|
| 128 |
+
padding: var(--space-6);
|
| 129 |
+
margin-bottom: var(--space-4);
|
| 130 |
+
box-shadow: var(--shadow-md);
|
| 131 |
+
transition: all 0.2s ease;
|
| 132 |
+
color: var(--gray-800);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.modern-card:hover {
|
| 136 |
+
border-color: var(--primary-purple);
|
| 137 |
+
box-shadow: var(--shadow-lg);
|
| 138 |
+
transform: translateY(-2px);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* Status Cards */
|
| 142 |
+
.status-grid {
|
| 143 |
+
display: grid;
|
| 144 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 145 |
+
gap: var(--space-4);
|
| 146 |
+
margin-bottom: var(--space-6);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.status-card {
|
| 150 |
+
background: white;
|
| 151 |
+
border: 1px solid var(--gray-200);
|
| 152 |
+
border-radius: var(--radius-lg);
|
| 153 |
+
padding: var(--space-5);
|
| 154 |
+
text-align: center;
|
| 155 |
+
transition: all 0.2s ease;
|
| 156 |
+
box-shadow: var(--shadow-sm);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.status-card:hover {
|
| 160 |
+
transform: translateY(-2px);
|
| 161 |
+
box-shadow: var(--shadow-md);
|
| 162 |
+
border-color: var(--primary-purple);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.status-card h4 {
|
| 166 |
+
margin: 0 0 var(--space-2) 0;
|
| 167 |
+
font-size: var(--font-size-sm);
|
| 168 |
+
color: var(--gray-600);
|
| 169 |
+
text-transform: uppercase;
|
| 170 |
+
letter-spacing: 0.05em;
|
| 171 |
+
font-weight: 600;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.status-card .value {
|
| 175 |
+
font-size: var(--font-size-2xl);
|
| 176 |
+
font-weight: 800;
|
| 177 |
+
color: var(--primary-purple);
|
| 178 |
+
margin: 0;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.status-card .subtitle {
|
| 182 |
+
font-size: var(--font-size-xs);
|
| 183 |
+
color: var(--gray-500);
|
| 184 |
+
margin: var(--space-1) 0 0 0;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* Modern Buttons */
|
| 188 |
+
.modern-btn {
|
| 189 |
+
background: var(--primary-purple);
|
| 190 |
+
color: white;
|
| 191 |
+
border: none;
|
| 192 |
+
border-radius: var(--radius-md);
|
| 193 |
+
padding: var(--space-3) var(--space-6);
|
| 194 |
+
font-weight: 600;
|
| 195 |
+
font-size: var(--font-size-sm);
|
| 196 |
+
cursor: pointer;
|
| 197 |
+
transition: all 0.2s ease;
|
| 198 |
+
box-shadow: var(--shadow-sm);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.modern-btn:hover {
|
| 202 |
+
background: #5b21b6;
|
| 203 |
+
transform: translateY(-1px);
|
| 204 |
+
box-shadow: var(--shadow-md);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.modern-btn:active {
|
| 208 |
+
transform: translateY(0);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.modern-btn-secondary {
|
| 212 |
+
background: var(--gray-700);
|
| 213 |
+
color: var(--gray-100);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.modern-btn-secondary:hover {
|
| 217 |
+
background: var(--gray-600);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.modern-btn-success {
|
| 221 |
+
background: var(--accent-green);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.modern-btn-success:hover {
|
| 225 |
+
background: #059669;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.modern-btn-danger {
|
| 229 |
+
background: var(--accent-red);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.modern-btn-danger:hover {
|
| 233 |
+
background: #dc2626;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* Form Elements */
|
| 237 |
+
.modern-input {
|
| 238 |
+
background: white;
|
| 239 |
+
border: 1px solid var(--gray-300);
|
| 240 |
+
border-radius: var(--radius-md);
|
| 241 |
+
padding: var(--space-3);
|
| 242 |
+
color: var(--gray-800);
|
| 243 |
+
font-size: var(--font-size-sm);
|
| 244 |
+
transition: all 0.2s ease;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.modern-input:focus {
|
| 248 |
+
outline: none;
|
| 249 |
+
border-color: var(--primary-purple);
|
| 250 |
+
box-shadow: 0 0 0 3px rgb(107 70 193 / 0.1);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* Hero Section - Laurier Style */
|
| 254 |
+
.hero-section {
|
| 255 |
+
background: var(--primary-purple);
|
| 256 |
+
border-radius: var(--radius-2xl);
|
| 257 |
+
padding: var(--space-10);
|
| 258 |
+
margin-bottom: var(--space-8);
|
| 259 |
+
text-align: left;
|
| 260 |
+
color: white;
|
| 261 |
+
box-shadow: var(--shadow-xl);
|
| 262 |
+
position: relative;
|
| 263 |
+
overflow: hidden;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.hero-section::before {
|
| 267 |
+
content: '';
|
| 268 |
+
position: absolute;
|
| 269 |
+
top: 0;
|
| 270 |
+
right: 0;
|
| 271 |
+
width: 40%;
|
| 272 |
+
height: 100%;
|
| 273 |
+
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
|
| 274 |
+
border-radius: 0 var(--radius-2xl) var(--radius-2xl) 0;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.hero-section h1 {
|
| 278 |
+
color: white;
|
| 279 |
+
margin-bottom: var(--space-4);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.hero-section p {
|
| 283 |
+
font-size: var(--font-size-lg);
|
| 284 |
+
opacity: 0.9;
|
| 285 |
+
margin: 0;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/* Progress Indicators */
|
| 289 |
+
.progress-bar {
|
| 290 |
+
background: var(--gray-700);
|
| 291 |
+
border-radius: var(--radius-lg);
|
| 292 |
+
height: 8px;
|
| 293 |
+
overflow: hidden;
|
| 294 |
+
margin: var(--space-2) 0;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.progress-fill {
|
| 298 |
+
background: linear-gradient(90deg, var(--primary-purple), var(--primary-gold));
|
| 299 |
+
height: 100%;
|
| 300 |
+
transition: width 0.3s ease;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/* Badges */
|
| 304 |
+
.badge {
|
| 305 |
+
display: inline-block;
|
| 306 |
+
padding: var(--space-1) var(--space-2);
|
| 307 |
+
border-radius: var(--radius-sm);
|
| 308 |
+
font-size: var(--font-size-xs);
|
| 309 |
+
font-weight: 600;
|
| 310 |
+
text-transform: uppercase;
|
| 311 |
+
letter-spacing: 0.05em;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.badge-success {
|
| 315 |
+
background: var(--accent-green);
|
| 316 |
+
color: white;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.badge-warning {
|
| 320 |
+
background: var(--accent-orange);
|
| 321 |
+
color: white;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.badge-danger {
|
| 325 |
+
background: var(--accent-red);
|
| 326 |
+
color: white;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.badge-info {
|
| 330 |
+
background: var(--accent-blue);
|
| 331 |
+
color: white;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/* Responsive Design */
|
| 335 |
+
@media (max-width: 768px) {
|
| 336 |
+
.main .block-container {
|
| 337 |
+
padding: var(--space-4) var(--space-2);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.status-grid {
|
| 341 |
+
grid-template-columns: 1fr;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.hero-section {
|
| 345 |
+
padding: var(--space-6);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
h1 {
|
| 349 |
+
font-size: var(--font-size-2xl);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* Loading States */
|
| 354 |
+
.loading {
|
| 355 |
+
display: inline-block;
|
| 356 |
+
width: 20px;
|
| 357 |
+
height: 20px;
|
| 358 |
+
border: 3px solid var(--gray-600);
|
| 359 |
+
border-radius: 50%;
|
| 360 |
+
border-top-color: var(--primary-purple);
|
| 361 |
+
animation: spin 1s ease-in-out infinite;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
@keyframes spin {
|
| 365 |
+
to { transform: rotate(360deg); }
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/* Animations */
|
| 369 |
+
.fade-in {
|
| 370 |
+
animation: fadeIn 0.5s ease-in;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
@keyframes fadeIn {
|
| 374 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 375 |
+
to { opacity: 1; transform: translateY(0); }
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.slide-in {
|
| 379 |
+
animation: slideIn 0.3s ease-out;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
@keyframes slideIn {
|
| 383 |
+
from { transform: translateX(-100%); }
|
| 384 |
+
to { transform: translateX(0); }
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* Accessibility */
|
| 388 |
+
.sr-only {
|
| 389 |
+
position: absolute;
|
| 390 |
+
width: 1px;
|
| 391 |
+
height: 1px;
|
| 392 |
+
padding: 0;
|
| 393 |
+
margin: -1px;
|
| 394 |
+
overflow: hidden;
|
| 395 |
+
clip: rect(0, 0, 0, 0);
|
| 396 |
+
white-space: nowrap;
|
| 397 |
+
border: 0;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/* Focus states for accessibility */
|
| 401 |
+
button:focus,
|
| 402 |
+
input:focus,
|
| 403 |
+
select:focus,
|
| 404 |
+
textarea:focus {
|
| 405 |
+
outline: 2px solid var(--primary-purple);
|
| 406 |
+
outline-offset: 2px;
|
| 407 |
+
}
|
| 408 |
+
</style>
|
| 409 |
+
"""
|
| 410 |
+
|
| 411 |
+
@staticmethod
|
| 412 |
+
def create_hero_section(title: str, subtitle: str, user_email: str = None) -> str:
|
| 413 |
+
"""Create a Laurier-style hero section"""
|
| 414 |
+
if user_email:
|
| 415 |
+
return f"""
|
| 416 |
+
<div class="hero-section fade-in">
|
| 417 |
+
<div style="position: relative; z-index: 2;">
|
| 418 |
+
<h1 style="font-size: 2.5rem; margin-bottom: var(--space-4); color: white;">💜💛 {title}</h1>
|
| 419 |
+
<p style="font-size: 1.25rem; margin-bottom: var(--space-6); color: white; opacity: 0.9;">{subtitle}</p>
|
| 420 |
+
<div style="margin-top: var(--space-6); padding: var(--space-5); background: rgba(255,255,255,0.15); border-radius: var(--radius-lg); backdrop-filter: blur(10px);">
|
| 421 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8; color: white;">Welcome,</div>
|
| 422 |
+
<div style="font-weight: 800; font-size: var(--font-size-xl); margin: var(--space-2) 0; color: white;">{user_email}</div>
|
| 423 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8; color: white;">Thank you for showing up for the community today. 💜💛</div>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
"""
|
| 428 |
+
else:
|
| 429 |
+
return f"""
|
| 430 |
+
<div class="hero-section fade-in">
|
| 431 |
+
<div style="position: relative; z-index: 2;">
|
| 432 |
+
<h1 style="font-size: 2.5rem; margin-bottom: var(--space-4); color: white;">💜💛 {title}</h1>
|
| 433 |
+
<p style="font-size: 1.25rem; color: white; opacity: 0.9;">{subtitle}</p>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
"""
|
| 437 |
+
|
| 438 |
+
@staticmethod
|
| 439 |
+
def create_status_cards(data: Dict[str, Any]) -> str:
|
| 440 |
+
"""Create modern status cards"""
|
| 441 |
+
cards_html = '<div class="status-grid">'
|
| 442 |
+
|
| 443 |
+
for key, value in data.items():
|
| 444 |
+
if key == "shift_active":
|
| 445 |
+
cards_html += f"""
|
| 446 |
+
<div class="status-card slide-in">
|
| 447 |
+
<h4>Shift Active</h4>
|
| 448 |
+
<div class="value">{value}</div>
|
| 449 |
+
<div class="subtitle">since you signed in</div>
|
| 450 |
+
</div>
|
| 451 |
+
"""
|
| 452 |
+
elif key == "items_today":
|
| 453 |
+
cards_html += f"""
|
| 454 |
+
<div class="status-card slide-in">
|
| 455 |
+
<h4>Items Logged Today</h4>
|
| 456 |
+
<div class="value">{value}</div>
|
| 457 |
+
<div class="subtitle">items processed</div>
|
| 458 |
+
</div>
|
| 459 |
+
"""
|
| 460 |
+
elif key == "lifetime_hours":
|
| 461 |
+
cards_html += f"""
|
| 462 |
+
<div class="status-card slide-in">
|
| 463 |
+
<h4>Lifetime Hours</h4>
|
| 464 |
+
<div class="value">{value}</div>
|
| 465 |
+
<div class="subtitle">volunteer hours</div>
|
| 466 |
+
</div>
|
| 467 |
+
"""
|
| 468 |
+
|
| 469 |
+
cards_html += '</div>'
|
| 470 |
+
return cards_html
|
| 471 |
+
|
| 472 |
+
@staticmethod
|
| 473 |
+
def create_modern_form_section(title: str, description: str = None) -> str:
|
| 474 |
+
"""Create a modern form section header"""
|
| 475 |
+
desc_html = f'<p style="color: var(--gray-400); margin-bottom: var(--space-4);">{description}</p>' if description else ''
|
| 476 |
+
return f"""
|
| 477 |
+
<div class="modern-card fade-in">
|
| 478 |
+
<h3>{title}</h3>
|
| 479 |
+
{desc_html}
|
| 480 |
+
"""
|
| 481 |
+
|
| 482 |
+
@staticmethod
|
| 483 |
+
def create_progress_indicator(current: int, total: int, label: str) -> str:
|
| 484 |
+
"""Create a modern progress indicator"""
|
| 485 |
+
percentage = (current / total * 100) if total > 0 else 0
|
| 486 |
+
return f"""
|
| 487 |
+
<div style="margin: var(--space-4) 0;">
|
| 488 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
|
| 489 |
+
<span style="font-weight: 600; color: var(--gray-300);">{label}</span>
|
| 490 |
+
<span style="font-weight: 700; color: var(--primary-gold);">{current}/{total}</span>
|
| 491 |
+
</div>
|
| 492 |
+
<div class="progress-bar">
|
| 493 |
+
<div class="progress-fill" style="width: {percentage}%;"></div>
|
| 494 |
+
</div>
|
| 495 |
+
</div>
|
| 496 |
+
"""
|
| 497 |
+
|
| 498 |
+
@staticmethod
|
| 499 |
+
def create_badge(text: str, variant: str = "info") -> str:
|
| 500 |
+
"""Create a modern badge"""
|
| 501 |
+
return f'<span class="badge badge-{variant}">{text}</span>'
|
| 502 |
+
|
| 503 |
+
@staticmethod
|
| 504 |
+
def create_loading_spinner() -> str:
|
| 505 |
+
"""Create a loading spinner"""
|
| 506 |
+
return '<div class="loading"></div>'
|
| 507 |
+
|
| 508 |
+
def apply_modern_ui():
|
| 509 |
+
"""Apply modern UI styling to the Streamlit app"""
|
| 510 |
+
st.markdown(ModernUIComponents.get_modern_css(), unsafe_allow_html=True)
|
| 511 |
+
|
| 512 |
+
def create_modern_layout():
|
| 513 |
+
"""Create a modern layout structure"""
|
| 514 |
+
return {
|
| 515 |
+
"container_style": "max-width: 1200px; margin: 0 auto;",
|
| 516 |
+
"sidebar_style": "background: var(--secondary-dark); border-right: 1px solid var(--gray-700);",
|
| 517 |
+
"main_style": "background: var(--primary-dark); padding: var(--space-6);"
|
| 518 |
+
}
|
backups/pre_modern_deployment_20250923_205210/backup_metadata.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backup_name": "pre_modern_deployment_20250923_205210",
|
| 3 |
+
"timestamp": "20250923_205210",
|
| 4 |
+
"description": "pre_modern_deployment",
|
| 5 |
+
"files_backed_up": [
|
| 6 |
+
"streamlit_app.py",
|
| 7 |
+
"streamlit_app_modern.py",
|
| 8 |
+
".streamlit/secrets.toml",
|
| 9 |
+
"requirements.txt",
|
| 10 |
+
"ui_improvements.py",
|
| 11 |
+
"test_app.py"
|
| 12 |
+
],
|
| 13 |
+
"git_status": {
|
| 14 |
+
"modified_files": [
|
| 15 |
+
"M requirements.txt",
|
| 16 |
+
" M streamlit_app.py",
|
| 17 |
+
"?? .streamlit/",
|
| 18 |
+
"?? .venv/",
|
| 19 |
+
"?? COLOR_SCHEME_UPDATE.md",
|
| 20 |
+
"?? DEPLOYMENT_QUICK_REFERENCE.md",
|
| 21 |
+
"?? UI_IMPROVEMENTS_GUIDE.md",
|
| 22 |
+
"?? __pycache__/",
|
| 23 |
+
"?? backups/",
|
| 24 |
+
"?? care_count.log",
|
| 25 |
+
"?? deploy_modern.py",
|
| 26 |
+
"?? deployment.log",
|
| 27 |
+
"?? streamlit_app_backup_20250923_195853.py",
|
| 28 |
+
"?? streamlit_app_modern.py",
|
| 29 |
+
"?? test_app.py",
|
| 30 |
+
"?? test_results.log",
|
| 31 |
+
"?? ui_improvements.py"
|
| 32 |
+
],
|
| 33 |
+
"return_code": 0
|
| 34 |
+
}
|
| 35 |
+
}
|
backups/pre_modern_deployment_20250923_205210/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_modern_deployment_20250923_205210/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_modern_deployment_20250923_205210/streamlit_app.py
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": json.dumps(details) if isinstance(details, dict) else str(details)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
# Log to file
|
| 138 |
+
if level == "error":
|
| 139 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 140 |
+
elif level == "warning":
|
| 141 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 142 |
+
else:
|
| 143 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 144 |
+
|
| 145 |
+
# Log to database (without level field to avoid schema issues)
|
| 146 |
+
try:
|
| 147 |
+
sb.table("events").insert(log_data).execute()
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.warning(f"Failed to log event to database (continuing): {e}")
|
| 150 |
+
|
| 151 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 152 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 153 |
+
"""Enhanced authentication with modern UI"""
|
| 154 |
+
if "auth_email" not in st.session_state:
|
| 155 |
+
st.session_state["auth_email"] = None
|
| 156 |
+
if "user_email" in st.session_state:
|
| 157 |
+
return True, st.session_state["user_email"]
|
| 158 |
+
|
| 159 |
+
# Modern hero section for login
|
| 160 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 161 |
+
"Care Count",
|
| 162 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 163 |
+
), unsafe_allow_html=True)
|
| 164 |
+
|
| 165 |
+
# Modern login form
|
| 166 |
+
with st.container():
|
| 167 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 168 |
+
st.subheader("🔐 Sign In")
|
| 169 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 170 |
+
|
| 171 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 172 |
+
email = st.text_input(
|
| 173 |
+
"Email Address",
|
| 174 |
+
value=st.session_state.get("auth_email") or "",
|
| 175 |
+
placeholder="your.email@example.com",
|
| 176 |
+
help="Enter your email address to receive a secure 6-digit login code. This code will be sent to your email and is valid for 10 minutes."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 180 |
+
with col2:
|
| 181 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 182 |
+
|
| 183 |
+
if send:
|
| 184 |
+
if not email or "@" not in email:
|
| 185 |
+
st.error("Please enter a valid email address.")
|
| 186 |
+
else:
|
| 187 |
+
try:
|
| 188 |
+
with st.spinner("Sending login code..."):
|
| 189 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 190 |
+
st.session_state["auth_email"] = email
|
| 191 |
+
st.success("✅ Login code sent! Check your email.")
|
| 192 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 193 |
+
except Exception as e:
|
| 194 |
+
st.error(f"❌ Could not send code: {e}")
|
| 195 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 196 |
+
|
| 197 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 198 |
+
|
| 199 |
+
# OTP verification form
|
| 200 |
+
if st.session_state.get("auth_email"):
|
| 201 |
+
with st.container():
|
| 202 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 203 |
+
st.subheader("🔢 Verify Code")
|
| 204 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 205 |
+
|
| 206 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 207 |
+
code = st.text_input(
|
| 208 |
+
"Verification Code",
|
| 209 |
+
max_chars=6,
|
| 210 |
+
placeholder="123456",
|
| 211 |
+
help="Enter the 6-digit verification code that was sent to your email. Check your spam folder if you don't see it in your inbox."
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 215 |
+
with col2:
|
| 216 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 217 |
+
|
| 218 |
+
if ok:
|
| 219 |
+
if len(code) != 6 or not code.isdigit():
|
| 220 |
+
st.error("Please enter a valid 6-digit code.")
|
| 221 |
+
else:
|
| 222 |
+
try:
|
| 223 |
+
with st.spinner("Verifying code..."):
|
| 224 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 225 |
+
if res and res.user:
|
| 226 |
+
email = st.session_state["auth_email"]
|
| 227 |
+
|
| 228 |
+
# Enhanced volunteer upsert
|
| 229 |
+
volunteer_data = {
|
| 230 |
+
"email": email,
|
| 231 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 232 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_ended_at": None,
|
| 234 |
+
"login_count": 1 # Track login frequency
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 238 |
+
|
| 239 |
+
st.session_state["user_email"] = email
|
| 240 |
+
st.session_state["shift_started"] = True
|
| 241 |
+
st.session_state["last_activity_at"] = local_now()
|
| 242 |
+
|
| 243 |
+
log_event("login_success", email, {"method": "otp"})
|
| 244 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 245 |
+
st.balloons()
|
| 246 |
+
return True, email
|
| 247 |
+
except Exception as e:
|
| 248 |
+
st.error(f"❌ Verification failed: {e}")
|
| 249 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 250 |
+
|
| 251 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 252 |
+
|
| 253 |
+
return False, None
|
| 254 |
+
|
| 255 |
+
def end_shift(email: str, reason: str):
|
| 256 |
+
"""Enhanced shift ending with better logging"""
|
| 257 |
+
try:
|
| 258 |
+
end_time = datetime.utcnow().isoformat()
|
| 259 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 260 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 263 |
+
|
| 264 |
+
def guard_cutoff_and_idle(email: str):
|
| 265 |
+
"""Enhanced session management with better UX"""
|
| 266 |
+
now = local_now()
|
| 267 |
+
last = st.session_state.get("last_activity_at")
|
| 268 |
+
|
| 269 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 270 |
+
end_shift(email, "inactivity")
|
| 271 |
+
st.session_state.clear()
|
| 272 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 273 |
+
st.stop()
|
| 274 |
+
|
| 275 |
+
st.session_state["last_activity_at"] = now
|
| 276 |
+
|
| 277 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 278 |
+
if now >= cutoff:
|
| 279 |
+
end_shift(email, "cutoff_8pm")
|
| 280 |
+
st.session_state.clear()
|
| 281 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 282 |
+
st.stop()
|
| 283 |
+
|
| 284 |
+
# ------------------------ Main App Flow ------------------------
|
| 285 |
+
def main():
|
| 286 |
+
"""Main application flow with modern UI"""
|
| 287 |
+
|
| 288 |
+
# Authentication
|
| 289 |
+
signed_in, user_email = auth_block()
|
| 290 |
+
if not signed_in:
|
| 291 |
+
st.stop()
|
| 292 |
+
|
| 293 |
+
guard_cutoff_and_idle(user_email)
|
| 294 |
+
|
| 295 |
+
# Modern welcome section
|
| 296 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 297 |
+
"Care Count Dashboard",
|
| 298 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 299 |
+
user_email
|
| 300 |
+
), unsafe_allow_html=True)
|
| 301 |
+
|
| 302 |
+
# Enhanced status cards
|
| 303 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 304 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 305 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 306 |
+
|
| 307 |
+
mins_active = 0
|
| 308 |
+
try:
|
| 309 |
+
if shift_started_at:
|
| 310 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 311 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
+
|
| 315 |
+
status_data = {
|
| 316 |
+
"shift_active": f"{mins_active} min",
|
| 317 |
+
"items_today": items_today(user_email),
|
| 318 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 322 |
+
|
| 323 |
+
# Modern sign-out button
|
| 324 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 325 |
+
with col2:
|
| 326 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 327 |
+
end_shift(user_email, "manual")
|
| 328 |
+
st.session_state.clear()
|
| 329 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 330 |
+
st.rerun()
|
| 331 |
+
|
| 332 |
+
# Enhanced visit management section
|
| 333 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 334 |
+
"🪪 Visit Management",
|
| 335 |
+
"Start and manage student visits with unique tracking codes"
|
| 336 |
+
), unsafe_allow_html=True)
|
| 337 |
+
|
| 338 |
+
active_visit = st.session_state.get("active_visit")
|
| 339 |
+
|
| 340 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 341 |
+
|
| 342 |
+
with col1:
|
| 343 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary", help="Start a new student visit session. This will create a unique visit code for tracking items."):
|
| 344 |
+
try:
|
| 345 |
+
with st.spinner("Creating visit..."):
|
| 346 |
+
payload = {
|
| 347 |
+
"visit_code": fallback_visit_code(),
|
| 348 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 349 |
+
"ended_at": None,
|
| 350 |
+
"created_by": user_email
|
| 351 |
+
}
|
| 352 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 353 |
+
if not v.get("visit_code"):
|
| 354 |
+
v["visit_code"] = payload["visit_code"]
|
| 355 |
+
st.session_state["active_visit"] = v
|
| 356 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 357 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 358 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 359 |
+
st.rerun()
|
| 360 |
+
except Exception as e:
|
| 361 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 362 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 363 |
+
|
| 364 |
+
with col2:
|
| 365 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary", help="End the current student visit session. This will finalize the visit and prepare for the next student."):
|
| 366 |
+
try:
|
| 367 |
+
with st.spinner("Ending visit..."):
|
| 368 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 369 |
+
.eq("id", active_visit["id"]).execute()
|
| 370 |
+
st.success("✅ Visit completed successfully")
|
| 371 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 372 |
+
st.session_state.pop("active_visit", None)
|
| 373 |
+
st.rerun()
|
| 374 |
+
except Exception as e:
|
| 375 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 376 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 377 |
+
|
| 378 |
+
with col3:
|
| 379 |
+
if st.session_state.get("active_visit"):
|
| 380 |
+
v = st.session_state["active_visit"]
|
| 381 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 382 |
+
else:
|
| 383 |
+
st.info("No active visit")
|
| 384 |
+
|
| 385 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 386 |
+
|
| 387 |
+
# Enhanced item identification section
|
| 388 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 389 |
+
"📸 Item Identification",
|
| 390 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 391 |
+
), unsafe_allow_html=True)
|
| 392 |
+
|
| 393 |
+
col1, col2 = st.columns(2)
|
| 394 |
+
|
| 395 |
+
with col1:
|
| 396 |
+
st.subheader("📷 Camera Capture")
|
| 397 |
+
cam = st.camera_input("Take a photo of the item", help="Use your device's camera to take a clear photo of the item. Make sure the item is well-lit and clearly visible in the frame.")
|
| 398 |
+
|
| 399 |
+
with col2:
|
| 400 |
+
st.subheader("📁 File Upload")
|
| 401 |
+
up = st.file_uploader(
|
| 402 |
+
"Upload an image",
|
| 403 |
+
type=["png","jpg","jpeg"],
|
| 404 |
+
help="Upload a photo of the item from your device. Supported formats: PNG, JPG, JPEG. Maximum file size: 200MB."
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
img_file = cam or up
|
| 408 |
+
if img_file:
|
| 409 |
+
try:
|
| 410 |
+
img = Image.open(img_file).convert("RGB")
|
| 411 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 412 |
+
|
| 413 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary", help="Use AI to automatically identify the item in the image. This will analyze the photo and suggest an item name."):
|
| 414 |
+
with st.spinner("Analyzing image with AI..."):
|
| 415 |
+
t0 = time.time()
|
| 416 |
+
try:
|
| 417 |
+
pre = preprocess_for_label(img)
|
| 418 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 419 |
+
processing_time = time.time() - t0
|
| 420 |
+
|
| 421 |
+
norm = normalize_item_name(raw)
|
| 422 |
+
|
| 423 |
+
if raw:
|
| 424 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 425 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 426 |
+
|
| 427 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 428 |
+
st.session_state["last_activity_at"] = local_now()
|
| 429 |
+
|
| 430 |
+
log_event("item_identified", user_email, {
|
| 431 |
+
"raw_name": raw,
|
| 432 |
+
"normalized_name": norm,
|
| 433 |
+
"processing_time": processing_time
|
| 434 |
+
})
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 438 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 439 |
+
except Exception as e:
|
| 440 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 441 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 442 |
+
|
| 443 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 444 |
+
|
| 445 |
+
# Enhanced item logging section
|
| 446 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 447 |
+
"📬 Item Logging",
|
| 448 |
+
"Log items to the current visit with detailed information"
|
| 449 |
+
), unsafe_allow_html=True)
|
| 450 |
+
|
| 451 |
+
col1, col2 = st.columns([2, 1])
|
| 452 |
+
|
| 453 |
+
with col1:
|
| 454 |
+
item_name = st.text_input(
|
| 455 |
+
"Item Name",
|
| 456 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 457 |
+
placeholder="Enter item name or use AI detection above",
|
| 458 |
+
help="Enter the name of the item. You can type it manually or use the AI detection feature above to automatically identify it from a photo."
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
with col2:
|
| 462 |
+
quantity = st.number_input(
|
| 463 |
+
"Quantity",
|
| 464 |
+
min_value=1,
|
| 465 |
+
max_value=9999,
|
| 466 |
+
step=1,
|
| 467 |
+
value=1,
|
| 468 |
+
help="Number of items"
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
col3, col4 = st.columns(2)
|
| 472 |
+
|
| 473 |
+
with col3:
|
| 474 |
+
category = st.text_input(
|
| 475 |
+
"Category (optional)",
|
| 476 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 477 |
+
help="Item category for better organization"
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
with col4:
|
| 481 |
+
unit = st.text_input(
|
| 482 |
+
"Unit (optional)",
|
| 483 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 484 |
+
help="Unit of measurement"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
barcode = st.text_input(
|
| 488 |
+
"Barcode (optional)",
|
| 489 |
+
placeholder="Scan or enter barcode",
|
| 490 |
+
help="Product barcode for inventory tracking"
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 494 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary", help="Save the item to the current visit. You must start a visit first before you can log items."):
|
| 495 |
+
v = st.session_state.get("active_visit")
|
| 496 |
+
if not v:
|
| 497 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 498 |
+
else:
|
| 499 |
+
name_clean = clean_text(item_name, 120)
|
| 500 |
+
if not name_clean:
|
| 501 |
+
st.warning("⚠️ Item name is required.")
|
| 502 |
+
else:
|
| 503 |
+
with st.spinner("Saving item..."):
|
| 504 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 505 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 506 |
+
|
| 507 |
+
try:
|
| 508 |
+
ok, msg = try_rpc_ingest(
|
| 509 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 510 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 511 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
if ok:
|
| 515 |
+
st.success("✅ Item logged successfully!")
|
| 516 |
+
log_event("item_logged", user_email, {
|
| 517 |
+
"visit_id": v["id"],
|
| 518 |
+
"item_name": name_clean,
|
| 519 |
+
"quantity": quantity
|
| 520 |
+
})
|
| 521 |
+
else:
|
| 522 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 523 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 524 |
+
clean_text(category,80), clean_text(unit,40),
|
| 525 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 526 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 527 |
+
log_event("item_logged_fallback", user_email, {
|
| 528 |
+
"visit_id": v["id"],
|
| 529 |
+
"item_name": name_clean,
|
| 530 |
+
"quantity": quantity
|
| 531 |
+
})
|
| 532 |
+
except Exception as e:
|
| 533 |
+
try:
|
| 534 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 535 |
+
clean_text(category,80), clean_text(unit,40),
|
| 536 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 537 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 538 |
+
log_event("item_logged_fallback", user_email, {
|
| 539 |
+
"visit_id": v["id"],
|
| 540 |
+
"item_name": name_clean,
|
| 541 |
+
"quantity": quantity
|
| 542 |
+
})
|
| 543 |
+
except Exception as e2:
|
| 544 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 545 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 546 |
+
|
| 547 |
+
st.session_state["last_activity_at"] = local_now()
|
| 548 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 549 |
+
st.rerun()
|
| 550 |
+
|
| 551 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 552 |
+
|
| 553 |
+
# Enhanced visit items view
|
| 554 |
+
if st.session_state.get("active_visit"):
|
| 555 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 556 |
+
"🧾 Current Visit Items",
|
| 557 |
+
"Review and manage items in the current visit"
|
| 558 |
+
), unsafe_allow_html=True)
|
| 559 |
+
|
| 560 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 561 |
+
if rows:
|
| 562 |
+
df = pd.DataFrame(rows)
|
| 563 |
+
|
| 564 |
+
# Enhanced dataframe display
|
| 565 |
+
st.dataframe(
|
| 566 |
+
df,
|
| 567 |
+
use_container_width=True,
|
| 568 |
+
hide_index=True,
|
| 569 |
+
column_config={
|
| 570 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 571 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 572 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 573 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 574 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 575 |
+
}
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
# Enhanced delete functionality
|
| 579 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 580 |
+
if rows:
|
| 581 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 582 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 583 |
+
|
| 584 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 585 |
+
item_id = item_options[selected_item]
|
| 586 |
+
try:
|
| 587 |
+
# Try both tables safely
|
| 588 |
+
try:
|
| 589 |
+
delete_item("visit_items_p", int(item_id))
|
| 590 |
+
except Exception:
|
| 591 |
+
delete_item("visit_items", int(item_id))
|
| 592 |
+
|
| 593 |
+
st.success("✅ Item deleted successfully")
|
| 594 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 595 |
+
st.rerun()
|
| 596 |
+
except Exception as e:
|
| 597 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 598 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 599 |
+
else:
|
| 600 |
+
st.info("No items to delete")
|
| 601 |
+
else:
|
| 602 |
+
st.info("📝 No items logged for this visit yet.")
|
| 603 |
+
|
| 604 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 605 |
+
|
| 606 |
+
# Enhanced analytics section
|
| 607 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 608 |
+
"📈 Today's Analytics",
|
| 609 |
+
"Real-time insights into today's volunteer activity"
|
| 610 |
+
), unsafe_allow_html=True)
|
| 611 |
+
|
| 612 |
+
try:
|
| 613 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 614 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 615 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 616 |
+
|
| 617 |
+
if today:
|
| 618 |
+
visits = int(today.get("visits", 0))
|
| 619 |
+
items = int(today.get("items", 0))
|
| 620 |
+
|
| 621 |
+
col1, col2 = st.columns(2)
|
| 622 |
+
with col1:
|
| 623 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 624 |
+
with col2:
|
| 625 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 626 |
+
|
| 627 |
+
# Progress indicator
|
| 628 |
+
if visits > 0:
|
| 629 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 630 |
+
items, visits * 10, "Items per Visit Target"
|
| 631 |
+
), unsafe_allow_html=True)
|
| 632 |
+
else:
|
| 633 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 634 |
+
|
| 635 |
+
except Exception as e:
|
| 636 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 637 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 638 |
+
|
| 639 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 640 |
+
|
| 641 |
+
# Enhanced footer with helpful information
|
| 642 |
+
st.markdown("""
|
| 643 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 644 |
+
<h4>💡 Quick Tips</h4>
|
| 645 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 646 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 647 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 648 |
+
</p>
|
| 649 |
+
</div>
|
| 650 |
+
""", unsafe_allow_html=True)
|
| 651 |
+
|
| 652 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 653 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 654 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 655 |
+
try:
|
| 656 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 657 |
+
except Exception as e:
|
| 658 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 659 |
+
return None
|
| 660 |
+
|
| 661 |
+
def items_today(email: str) -> int:
|
| 662 |
+
"""Enhanced item counting with better error handling"""
|
| 663 |
+
try:
|
| 664 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 665 |
+
# Try partitioned table first
|
| 666 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 667 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 668 |
+
.eq("volunteer", email).execute().data
|
| 669 |
+
return len(data or [])
|
| 670 |
+
except Exception:
|
| 671 |
+
try:
|
| 672 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 673 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 674 |
+
.eq("volunteer", email).execute().data
|
| 675 |
+
return len(data or [])
|
| 676 |
+
except Exception as e:
|
| 677 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 678 |
+
return 0
|
| 679 |
+
|
| 680 |
+
def fallback_visit_code() -> str:
|
| 681 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 682 |
+
try:
|
| 683 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 684 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 685 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 686 |
+
seq = len(todays) + 1
|
| 687 |
+
except Exception:
|
| 688 |
+
seq = int(time.time()) % 1000
|
| 689 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 690 |
+
|
| 691 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 692 |
+
"""Convert image to PNG bytes"""
|
| 693 |
+
b = io.BytesIO()
|
| 694 |
+
img.save(b, format="PNG")
|
| 695 |
+
return b.getvalue()
|
| 696 |
+
|
| 697 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 698 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 699 |
+
img = img.convert("RGB")
|
| 700 |
+
w, h = img.size
|
| 701 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 702 |
+
if scale < 1.0:
|
| 703 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 704 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 705 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 706 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 707 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 708 |
+
return img
|
| 709 |
+
|
| 710 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 711 |
+
"""Enhanced AI item identification with better error handling"""
|
| 712 |
+
try:
|
| 713 |
+
if PROVIDER == "nebius":
|
| 714 |
+
if not NEBIUS_API_KEY:
|
| 715 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 716 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 717 |
+
elif PROVIDER == "featherless":
|
| 718 |
+
if not FEATH_API_KEY:
|
| 719 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 720 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 721 |
+
else:
|
| 722 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 723 |
+
except Exception as e:
|
| 724 |
+
logger.error(f"AI identification failed: {e}")
|
| 725 |
+
raise
|
| 726 |
+
|
| 727 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 728 |
+
"""Enhanced API communication with better error handling"""
|
| 729 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 730 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 731 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 732 |
+
payload = {
|
| 733 |
+
"model": model_id,
|
| 734 |
+
"temperature": 0,
|
| 735 |
+
"messages": [
|
| 736 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 737 |
+
{"role": "user", "content": [
|
| 738 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 739 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 740 |
+
]}
|
| 741 |
+
]
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
try:
|
| 745 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 746 |
+
if r.status_code != 200:
|
| 747 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 748 |
+
data = r.json()
|
| 749 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 750 |
+
except requests.exceptions.Timeout:
|
| 751 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 752 |
+
except requests.exceptions.RequestException as e:
|
| 753 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 754 |
+
|
| 755 |
+
def normalize_item_name(s: str) -> str:
|
| 756 |
+
"""Enhanced item name normalization"""
|
| 757 |
+
s = (s or "").strip()
|
| 758 |
+
if not s:
|
| 759 |
+
return ""
|
| 760 |
+
|
| 761 |
+
# Enhanced brand and type recognition
|
| 762 |
+
BRANDS = {
|
| 763 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 764 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 765 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 766 |
+
}
|
| 767 |
+
GENERIC_TYPES = {
|
| 768 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 769 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 770 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 771 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 775 |
+
for b in BRANDS:
|
| 776 |
+
low = low.replace(b, "")
|
| 777 |
+
|
| 778 |
+
chosen = None
|
| 779 |
+
for t in GENERIC_TYPES:
|
| 780 |
+
if t in low:
|
| 781 |
+
chosen = t
|
| 782 |
+
break
|
| 783 |
+
|
| 784 |
+
cleaned = " ".join(low.split())
|
| 785 |
+
return (chosen or cleaned.title())[:120]
|
| 786 |
+
|
| 787 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 788 |
+
"""Enhanced text cleaning"""
|
| 789 |
+
if not v:
|
| 790 |
+
return None
|
| 791 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 792 |
+
return v[:maxlen] if v else None
|
| 793 |
+
|
| 794 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 795 |
+
"""Enhanced ID generation for data integrity"""
|
| 796 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 797 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 798 |
+
|
| 799 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 800 |
+
category: Optional[str], unit: Optional[str],
|
| 801 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 802 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 803 |
+
try:
|
| 804 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 805 |
+
"p_email": email,
|
| 806 |
+
"p_visit_id": v_id,
|
| 807 |
+
"p_item_name": name,
|
| 808 |
+
"p_qty": qty,
|
| 809 |
+
"p_category": category,
|
| 810 |
+
"p_unit": unit,
|
| 811 |
+
"p_barcode": barcode,
|
| 812 |
+
"p_ts": ts_iso,
|
| 813 |
+
"p_ingest_id": ingest_id
|
| 814 |
+
}).execute()
|
| 815 |
+
|
| 816 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 817 |
+
if rows:
|
| 818 |
+
r0 = rows[0]
|
| 819 |
+
ok = bool(r0.get("ok", False))
|
| 820 |
+
msg = str(r0.get("msg", ""))
|
| 821 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 822 |
+
return True, "ok"
|
| 823 |
+
except Exception as e:
|
| 824 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 825 |
+
raise e
|
| 826 |
+
|
| 827 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 828 |
+
category: Optional[str], unit: Optional[str],
|
| 829 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 830 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 831 |
+
payload = {
|
| 832 |
+
"visit_id": v_id,
|
| 833 |
+
"timestamp": ts_iso,
|
| 834 |
+
"volunteer": email,
|
| 835 |
+
"item_name": name,
|
| 836 |
+
"category": category,
|
| 837 |
+
"unit": unit,
|
| 838 |
+
"qty": qty,
|
| 839 |
+
"barcode": barcode,
|
| 840 |
+
"weather_type": None,
|
| 841 |
+
"temp_c": None,
|
| 842 |
+
"ingest_id": ingest_id
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
try:
|
| 846 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 847 |
+
except Exception:
|
| 848 |
+
# Legacy table fallback
|
| 849 |
+
payload.pop("ingest_id", None)
|
| 850 |
+
sb.table("visit_items").insert(payload).execute()
|
| 851 |
+
|
| 852 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 853 |
+
"""Enhanced item loading with better error handling"""
|
| 854 |
+
try:
|
| 855 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 856 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 857 |
+
except Exception:
|
| 858 |
+
try:
|
| 859 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 860 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 861 |
+
except Exception as e:
|
| 862 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 863 |
+
return []
|
| 864 |
+
|
| 865 |
+
def delete_item(table: str, item_id: int):
|
| 866 |
+
"""Enhanced item deletion with better error handling"""
|
| 867 |
+
try:
|
| 868 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 869 |
+
except Exception as e:
|
| 870 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 871 |
+
raise e
|
| 872 |
+
|
| 873 |
+
# ------------------------ App Configuration Display ------------------------
|
| 874 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 875 |
+
st.sidebar.info(f"""
|
| 876 |
+
**Provider:** `{PROVIDER}`
|
| 877 |
+
**Model:** `{GEMMA_MODEL}`
|
| 878 |
+
**Timezone:** `{TZ}`
|
| 879 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 880 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 881 |
+
""")
|
| 882 |
+
|
| 883 |
+
# ------------------------ Main App Execution ------------------------
|
| 884 |
+
if __name__ == "__main__":
|
| 885 |
+
try:
|
| 886 |
+
main()
|
| 887 |
+
except Exception as e:
|
| 888 |
+
logger.error(f"Application error: {e}")
|
| 889 |
+
st.error(f"❌ Application error: {e}")
|
| 890 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_205210/streamlit_app_modern.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|
backups/pre_modern_deployment_20250923_205210/test_app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Care Count App Testing Framework
|
| 4 |
+
Tests UI/UX changes and backend functionality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Dict, Any, List
|
| 13 |
+
import subprocess
|
| 14 |
+
import requests
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# Setup logging
|
| 18 |
+
logging.basicConfig(
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 21 |
+
handlers=[
|
| 22 |
+
logging.FileHandler('test_results.log'),
|
| 23 |
+
logging.StreamHandler()
|
| 24 |
+
]
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
class CareCountTester:
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.test_results = []
|
| 31 |
+
self.app_url = "http://localhost:8501"
|
| 32 |
+
self.backup_dir = Path("backups")
|
| 33 |
+
self.backup_dir.mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
def create_backup(self, description: str = "manual_backup"):
|
| 36 |
+
"""Create a timestamped backup of current app state"""
|
| 37 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 38 |
+
backup_name = f"{description}_{timestamp}"
|
| 39 |
+
|
| 40 |
+
# Backup main files
|
| 41 |
+
files_to_backup = [
|
| 42 |
+
"streamlit_app.py",
|
| 43 |
+
".streamlit/secrets.toml",
|
| 44 |
+
"requirements.txt"
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
backup_path = self.backup_dir / backup_name
|
| 48 |
+
backup_path.mkdir(exist_ok=True)
|
| 49 |
+
|
| 50 |
+
for file_path in files_to_backup:
|
| 51 |
+
if os.path.exists(file_path):
|
| 52 |
+
subprocess.run(["cp", file_path, str(backup_path / os.path.basename(file_path))])
|
| 53 |
+
|
| 54 |
+
logger.info(f"Backup created: {backup_name}")
|
| 55 |
+
return backup_name
|
| 56 |
+
|
| 57 |
+
def restore_backup(self, backup_name: str):
|
| 58 |
+
"""Restore from a backup"""
|
| 59 |
+
backup_path = self.backup_dir / backup_name
|
| 60 |
+
|
| 61 |
+
if not backup_path.exists():
|
| 62 |
+
logger.error(f"Backup {backup_name} not found")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
# Restore files
|
| 66 |
+
files_to_restore = [
|
| 67 |
+
"streamlit_app.py",
|
| 68 |
+
".streamlit/secrets.toml",
|
| 69 |
+
"requirements.txt"
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
for file_path in files_to_restore:
|
| 73 |
+
backup_file = backup_path / os.path.basename(file_path)
|
| 74 |
+
if backup_file.exists():
|
| 75 |
+
subprocess.run(["cp", str(backup_file), file_path])
|
| 76 |
+
|
| 77 |
+
logger.info(f"Restored from backup: {backup_name}")
|
| 78 |
+
return True
|
| 79 |
+
|
| 80 |
+
def test_app_startup(self) -> bool:
|
| 81 |
+
"""Test if the app starts without errors"""
|
| 82 |
+
try:
|
| 83 |
+
# Check if app is already running
|
| 84 |
+
response = requests.get(self.app_url, timeout=5)
|
| 85 |
+
if response.status_code == 200:
|
| 86 |
+
logger.info("✅ App is running and accessible")
|
| 87 |
+
return True
|
| 88 |
+
else:
|
| 89 |
+
logger.error(f"❌ App returned status code: {response.status_code}")
|
| 90 |
+
return False
|
| 91 |
+
except requests.exceptions.RequestException as e:
|
| 92 |
+
logger.error(f"❌ App startup test failed: {e}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
def test_ui_elements(self) -> Dict[str, bool]:
|
| 96 |
+
"""Test key UI elements are present"""
|
| 97 |
+
results = {}
|
| 98 |
+
try:
|
| 99 |
+
response = requests.get(self.app_url, timeout=10)
|
| 100 |
+
content = response.text.lower()
|
| 101 |
+
|
| 102 |
+
# Test for key UI elements
|
| 103 |
+
ui_tests = {
|
| 104 |
+
"title_present": "care count" in content,
|
| 105 |
+
"signin_form": "sign in" in content or "email" in content,
|
| 106 |
+
"camera_input": "camera" in content or "webcam" in content,
|
| 107 |
+
"file_upload": "upload" in content or "file" in content,
|
| 108 |
+
"visit_management": "visit" in content,
|
| 109 |
+
"item_logging": "item" in content,
|
| 110 |
+
"css_styling": "style" in content or "css" in content
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
for test_name, result in ui_tests.items():
|
| 114 |
+
results[test_name] = result
|
| 115 |
+
status = "✅" if result else "❌"
|
| 116 |
+
logger.info(f"{status} {test_name}: {result}")
|
| 117 |
+
|
| 118 |
+
return results
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"❌ UI elements test failed: {e}")
|
| 122 |
+
return {}
|
| 123 |
+
|
| 124 |
+
def test_responsive_design(self) -> bool:
|
| 125 |
+
"""Test if the app has responsive design elements"""
|
| 126 |
+
try:
|
| 127 |
+
response = requests.get(self.app_url, timeout=10)
|
| 128 |
+
content = response.text
|
| 129 |
+
|
| 130 |
+
# Check for responsive design indicators
|
| 131 |
+
responsive_indicators = [
|
| 132 |
+
"container" in content,
|
| 133 |
+
"column" in content,
|
| 134 |
+
"responsive" in content,
|
| 135 |
+
"mobile" in content
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
responsive_score = sum(responsive_indicators) / len(responsive_indicators)
|
| 139 |
+
is_responsive = responsive_score >= 0.5
|
| 140 |
+
|
| 141 |
+
logger.info(f"📱 Responsive design score: {responsive_score:.2f} ({'✅' if is_responsive else '❌'})")
|
| 142 |
+
return is_responsive
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"❌ Responsive design test failed: {e}")
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
def test_performance(self) -> Dict[str, float]:
|
| 149 |
+
"""Test app performance metrics"""
|
| 150 |
+
try:
|
| 151 |
+
start_time = time.time()
|
| 152 |
+
response = requests.get(self.app_url, timeout=30)
|
| 153 |
+
load_time = time.time() - start_time
|
| 154 |
+
|
| 155 |
+
results = {
|
| 156 |
+
"load_time": load_time,
|
| 157 |
+
"status_code": response.status_code,
|
| 158 |
+
"content_size": len(response.content)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
logger.info(f"⚡ Load time: {load_time:.2f}s")
|
| 162 |
+
logger.info(f"📊 Content size: {len(response.content)} bytes")
|
| 163 |
+
|
| 164 |
+
return results
|
| 165 |
+
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"❌ Performance test failed: {e}")
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
def run_all_tests(self) -> Dict[str, Any]:
|
| 171 |
+
"""Run all tests and return comprehensive results"""
|
| 172 |
+
logger.info("🧪 Starting comprehensive app testing...")
|
| 173 |
+
|
| 174 |
+
# Create backup before testing
|
| 175 |
+
backup_name = self.create_backup("pre_test_backup")
|
| 176 |
+
|
| 177 |
+
test_results = {
|
| 178 |
+
"timestamp": datetime.now().isoformat(),
|
| 179 |
+
"backup_created": backup_name,
|
| 180 |
+
"startup_test": self.test_app_startup(),
|
| 181 |
+
"ui_elements": self.test_ui_elements(),
|
| 182 |
+
"responsive_design": self.test_responsive_design(),
|
| 183 |
+
"performance": self.test_performance()
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
# Calculate overall score
|
| 187 |
+
ui_score = sum(test_results["ui_elements"].values()) / len(test_results["ui_elements"]) if test_results["ui_elements"] else 0
|
| 188 |
+
overall_score = (
|
| 189 |
+
(1 if test_results["startup_test"] else 0) +
|
| 190 |
+
ui_score +
|
| 191 |
+
(1 if test_results["responsive_design"] else 0)
|
| 192 |
+
) / 3
|
| 193 |
+
|
| 194 |
+
test_results["overall_score"] = overall_score
|
| 195 |
+
test_results["status"] = "PASS" if overall_score >= 0.8 else "FAIL"
|
| 196 |
+
|
| 197 |
+
logger.info(f"🎯 Overall test score: {overall_score:.2f} ({test_results['status']})")
|
| 198 |
+
|
| 199 |
+
return test_results
|
| 200 |
+
|
| 201 |
+
def main():
|
| 202 |
+
"""Main testing function"""
|
| 203 |
+
tester = CareCountTester()
|
| 204 |
+
|
| 205 |
+
if len(sys.argv) > 1:
|
| 206 |
+
command = sys.argv[1]
|
| 207 |
+
|
| 208 |
+
if command == "backup":
|
| 209 |
+
description = sys.argv[2] if len(sys.argv) > 2 else "manual_backup"
|
| 210 |
+
tester.create_backup(description)
|
| 211 |
+
elif command == "restore":
|
| 212 |
+
if len(sys.argv) > 2:
|
| 213 |
+
tester.restore_backup(sys.argv[2])
|
| 214 |
+
else:
|
| 215 |
+
print("Usage: python test_app.py restore <backup_name>")
|
| 216 |
+
elif command == "test":
|
| 217 |
+
results = tester.run_all_tests()
|
| 218 |
+
print(f"\n📋 Test Results Summary:")
|
| 219 |
+
print(f"Status: {results['status']}")
|
| 220 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 221 |
+
print(f"Backup: {results['backup_created']}")
|
| 222 |
+
else:
|
| 223 |
+
print("Available commands: backup, restore, test")
|
| 224 |
+
else:
|
| 225 |
+
# Run all tests by default
|
| 226 |
+
results = tester.run_all_tests()
|
| 227 |
+
print(f"\n📋 Test Results Summary:")
|
| 228 |
+
print(f"Status: {results['status']}")
|
| 229 |
+
print(f"Score: {results['overall_score']:.2f}")
|
| 230 |
+
print(f"Backup: {results['backup_created']}")
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
main()
|
backups/pre_modern_deployment_20250923_205210/ui_improvements.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Care Count UI/UX Improvements Module
|
| 3 |
+
Industry-standard UI components and styling
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
import base64
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
class ModernUIComponents:
|
| 12 |
+
"""Modern UI components for Care Count app"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def get_modern_css() -> str:
|
| 16 |
+
"""Return modern, industry-standard CSS"""
|
| 17 |
+
return """
|
| 18 |
+
<style>
|
| 19 |
+
/* Modern Design System - Laurier University Inspired */
|
| 20 |
+
:root {
|
| 21 |
+
/* Color Palette - Laurier University Theme */
|
| 22 |
+
--primary-purple: #6b46c1; /* Laurier purple - more vibrant */
|
| 23 |
+
--primary-gold: #fbbf24; /* Laurier gold - warmer tone */
|
| 24 |
+
--primary-dark: #1e1b4b; /* Deep purple background */
|
| 25 |
+
--secondary-dark: #312e81; /* Slightly lighter purple */
|
| 26 |
+
--accent-blue: #3b82f6;
|
| 27 |
+
--accent-green: #10b981;
|
| 28 |
+
--accent-red: #ef4444;
|
| 29 |
+
--accent-orange: #f59e0b;
|
| 30 |
+
|
| 31 |
+
/* Neutral Colors */
|
| 32 |
+
--gray-50: #f9fafb;
|
| 33 |
+
--gray-100: #f3f4f6;
|
| 34 |
+
--gray-200: #e5e7eb;
|
| 35 |
+
--gray-300: #d1d5db;
|
| 36 |
+
--gray-400: #9ca3af;
|
| 37 |
+
--gray-500: #6b7280;
|
| 38 |
+
--gray-600: #4b5563;
|
| 39 |
+
--gray-700: #374151;
|
| 40 |
+
--gray-800: #1f2937;
|
| 41 |
+
--gray-900: #111827;
|
| 42 |
+
|
| 43 |
+
/* Typography */
|
| 44 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 45 |
+
--font-size-xs: 0.75rem;
|
| 46 |
+
--font-size-sm: 0.875rem;
|
| 47 |
+
--font-size-base: 1rem;
|
| 48 |
+
--font-size-lg: 1.125rem;
|
| 49 |
+
--font-size-xl: 1.25rem;
|
| 50 |
+
--font-size-2xl: 1.5rem;
|
| 51 |
+
--font-size-3xl: 1.875rem;
|
| 52 |
+
|
| 53 |
+
/* Spacing */
|
| 54 |
+
--space-1: 0.25rem;
|
| 55 |
+
--space-2: 0.5rem;
|
| 56 |
+
--space-3: 0.75rem;
|
| 57 |
+
--space-4: 1rem;
|
| 58 |
+
--space-5: 1.25rem;
|
| 59 |
+
--space-6: 1.5rem;
|
| 60 |
+
--space-8: 2rem;
|
| 61 |
+
--space-10: 2.5rem;
|
| 62 |
+
--space-12: 3rem;
|
| 63 |
+
|
| 64 |
+
/* Border Radius */
|
| 65 |
+
--radius-sm: 0.375rem;
|
| 66 |
+
--radius-md: 0.5rem;
|
| 67 |
+
--radius-lg: 0.75rem;
|
| 68 |
+
--radius-xl: 1rem;
|
| 69 |
+
--radius-2xl: 1.5rem;
|
| 70 |
+
|
| 71 |
+
/* Shadows */
|
| 72 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 73 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 74 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 75 |
+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Global Styles */
|
| 79 |
+
* {
|
| 80 |
+
box-sizing: border-box;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
body {
|
| 84 |
+
font-family: var(--font-family);
|
| 85 |
+
background: var(--gray-50);
|
| 86 |
+
color: var(--gray-800);
|
| 87 |
+
line-height: 1.6;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Streamlit Overrides */
|
| 91 |
+
.main .block-container {
|
| 92 |
+
padding: var(--space-6) var(--space-4);
|
| 93 |
+
max-width: 1200px;
|
| 94 |
+
background: var(--gray-50);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.stApp {
|
| 98 |
+
background: var(--gray-50);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Typography */
|
| 102 |
+
h1, h2, h3, h4, h5, h6 {
|
| 103 |
+
font-weight: 700;
|
| 104 |
+
letter-spacing: -0.025em;
|
| 105 |
+
color: var(--gray-800);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
h1 {
|
| 109 |
+
font-size: var(--font-size-3xl);
|
| 110 |
+
margin-bottom: var(--space-6);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
h2 {
|
| 114 |
+
font-size: var(--font-size-2xl);
|
| 115 |
+
margin-bottom: var(--space-4);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
h3 {
|
| 119 |
+
font-size: var(--font-size-xl);
|
| 120 |
+
margin-bottom: var(--space-3);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Modern Cards - Laurier Style */
|
| 124 |
+
.modern-card {
|
| 125 |
+
background: white;
|
| 126 |
+
border: 1px solid var(--gray-200);
|
| 127 |
+
border-radius: var(--radius-xl);
|
| 128 |
+
padding: var(--space-6);
|
| 129 |
+
margin-bottom: var(--space-4);
|
| 130 |
+
box-shadow: var(--shadow-md);
|
| 131 |
+
transition: all 0.2s ease;
|
| 132 |
+
color: var(--gray-800);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.modern-card:hover {
|
| 136 |
+
border-color: var(--primary-purple);
|
| 137 |
+
box-shadow: var(--shadow-lg);
|
| 138 |
+
transform: translateY(-2px);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.modern-card:hover h1,
|
| 142 |
+
.modern-card:hover h2,
|
| 143 |
+
.modern-card:hover h3,
|
| 144 |
+
.modern-card:hover h4,
|
| 145 |
+
.modern-card:hover h5,
|
| 146 |
+
.modern-card:hover h6,
|
| 147 |
+
.modern-card:hover p,
|
| 148 |
+
.modern-card:hover span,
|
| 149 |
+
.modern-card:hover div {
|
| 150 |
+
color: var(--gray-800) !important;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* Status Cards */
|
| 154 |
+
.status-grid {
|
| 155 |
+
display: grid;
|
| 156 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 157 |
+
gap: var(--space-4);
|
| 158 |
+
margin-bottom: var(--space-6);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.status-card {
|
| 162 |
+
background: white;
|
| 163 |
+
border: 1px solid var(--gray-200);
|
| 164 |
+
border-radius: var(--radius-lg);
|
| 165 |
+
padding: var(--space-5);
|
| 166 |
+
text-align: center;
|
| 167 |
+
transition: all 0.2s ease;
|
| 168 |
+
box-shadow: var(--shadow-sm);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.status-card:hover {
|
| 172 |
+
transform: translateY(-2px);
|
| 173 |
+
box-shadow: var(--shadow-md);
|
| 174 |
+
border-color: var(--primary-purple);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.status-card:hover h4,
|
| 178 |
+
.status-card:hover .value,
|
| 179 |
+
.status-card:hover .subtitle {
|
| 180 |
+
color: var(--gray-800) !important;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.status-card:hover .value {
|
| 184 |
+
color: var(--primary-purple) !important;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.status-card h4 {
|
| 188 |
+
margin: 0 0 var(--space-2) 0;
|
| 189 |
+
font-size: var(--font-size-sm);
|
| 190 |
+
color: var(--gray-600);
|
| 191 |
+
text-transform: uppercase;
|
| 192 |
+
letter-spacing: 0.05em;
|
| 193 |
+
font-weight: 600;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.status-card .value {
|
| 197 |
+
font-size: var(--font-size-2xl);
|
| 198 |
+
font-weight: 800;
|
| 199 |
+
color: var(--primary-purple);
|
| 200 |
+
margin: 0;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.status-card .subtitle {
|
| 204 |
+
font-size: var(--font-size-xs);
|
| 205 |
+
color: var(--gray-500);
|
| 206 |
+
margin: var(--space-1) 0 0 0;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* Modern Buttons */
|
| 210 |
+
.modern-btn {
|
| 211 |
+
background: var(--primary-purple);
|
| 212 |
+
color: white;
|
| 213 |
+
border: none;
|
| 214 |
+
border-radius: var(--radius-md);
|
| 215 |
+
padding: var(--space-3) var(--space-6);
|
| 216 |
+
font-weight: 600;
|
| 217 |
+
font-size: var(--font-size-sm);
|
| 218 |
+
cursor: pointer;
|
| 219 |
+
transition: all 0.2s ease;
|
| 220 |
+
box-shadow: var(--shadow-sm);
|
| 221 |
+
position: relative;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.modern-btn:hover {
|
| 225 |
+
background: #5b21b6;
|
| 226 |
+
transform: translateY(-1px);
|
| 227 |
+
box-shadow: var(--shadow-md);
|
| 228 |
+
color: white !important;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.modern-btn:active {
|
| 232 |
+
transform: translateY(0);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* Tooltip styles */
|
| 236 |
+
.tooltip {
|
| 237 |
+
position: relative;
|
| 238 |
+
display: inline-block;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.tooltip .tooltiptext {
|
| 242 |
+
visibility: hidden;
|
| 243 |
+
width: 200px;
|
| 244 |
+
background-color: var(--gray-800);
|
| 245 |
+
color: white;
|
| 246 |
+
text-align: center;
|
| 247 |
+
border-radius: var(--radius-md);
|
| 248 |
+
padding: var(--space-2) var(--space-3);
|
| 249 |
+
position: absolute;
|
| 250 |
+
z-index: 1;
|
| 251 |
+
bottom: 125%;
|
| 252 |
+
left: 50%;
|
| 253 |
+
margin-left: -100px;
|
| 254 |
+
opacity: 0;
|
| 255 |
+
transition: opacity 0.3s;
|
| 256 |
+
font-size: var(--font-size-xs);
|
| 257 |
+
box-shadow: var(--shadow-lg);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.tooltip .tooltiptext::after {
|
| 261 |
+
content: "";
|
| 262 |
+
position: absolute;
|
| 263 |
+
top: 100%;
|
| 264 |
+
left: 50%;
|
| 265 |
+
margin-left: -5px;
|
| 266 |
+
border-width: 5px;
|
| 267 |
+
border-style: solid;
|
| 268 |
+
border-color: var(--gray-800) transparent transparent transparent;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.tooltip:hover .tooltiptext {
|
| 272 |
+
visibility: visible;
|
| 273 |
+
opacity: 1;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.modern-btn-secondary {
|
| 277 |
+
background: var(--gray-700);
|
| 278 |
+
color: var(--gray-100);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.modern-btn-secondary:hover {
|
| 282 |
+
background: var(--gray-600);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.modern-btn-success {
|
| 286 |
+
background: var(--accent-green);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.modern-btn-success:hover {
|
| 290 |
+
background: #059669;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.modern-btn-danger {
|
| 294 |
+
background: var(--accent-red);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.modern-btn-danger:hover {
|
| 298 |
+
background: #dc2626;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/* Form Elements */
|
| 302 |
+
.modern-input {
|
| 303 |
+
background: white;
|
| 304 |
+
border: 1px solid var(--gray-300);
|
| 305 |
+
border-radius: var(--radius-md);
|
| 306 |
+
padding: var(--space-3);
|
| 307 |
+
color: var(--gray-800);
|
| 308 |
+
font-size: var(--font-size-sm);
|
| 309 |
+
transition: all 0.2s ease;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.modern-input:focus {
|
| 313 |
+
outline: none;
|
| 314 |
+
border-color: var(--primary-purple);
|
| 315 |
+
box-shadow: 0 0 0 3px rgb(107 70 193 / 0.1);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/* Hero Section - Laurier Style */
|
| 319 |
+
.hero-section {
|
| 320 |
+
background: var(--primary-purple);
|
| 321 |
+
border-radius: var(--radius-2xl);
|
| 322 |
+
padding: var(--space-10);
|
| 323 |
+
margin-bottom: var(--space-8);
|
| 324 |
+
text-align: left;
|
| 325 |
+
color: white;
|
| 326 |
+
box-shadow: var(--shadow-xl);
|
| 327 |
+
position: relative;
|
| 328 |
+
overflow: hidden;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.hero-section::before {
|
| 332 |
+
content: '';
|
| 333 |
+
position: absolute;
|
| 334 |
+
top: 0;
|
| 335 |
+
right: 0;
|
| 336 |
+
width: 40%;
|
| 337 |
+
height: 100%;
|
| 338 |
+
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
|
| 339 |
+
border-radius: 0 var(--radius-2xl) var(--radius-2xl) 0;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.hero-section h1 {
|
| 343 |
+
color: white;
|
| 344 |
+
margin-bottom: var(--space-4);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.hero-section p {
|
| 348 |
+
font-size: var(--font-size-lg);
|
| 349 |
+
opacity: 0.9;
|
| 350 |
+
margin: 0;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* Progress Indicators */
|
| 354 |
+
.progress-bar {
|
| 355 |
+
background: var(--gray-700);
|
| 356 |
+
border-radius: var(--radius-lg);
|
| 357 |
+
height: 8px;
|
| 358 |
+
overflow: hidden;
|
| 359 |
+
margin: var(--space-2) 0;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.progress-fill {
|
| 363 |
+
background: linear-gradient(90deg, var(--primary-purple), var(--primary-gold));
|
| 364 |
+
height: 100%;
|
| 365 |
+
transition: width 0.3s ease;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/* Badges */
|
| 369 |
+
.badge {
|
| 370 |
+
display: inline-block;
|
| 371 |
+
padding: var(--space-1) var(--space-2);
|
| 372 |
+
border-radius: var(--radius-sm);
|
| 373 |
+
font-size: var(--font-size-xs);
|
| 374 |
+
font-weight: 600;
|
| 375 |
+
text-transform: uppercase;
|
| 376 |
+
letter-spacing: 0.05em;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.badge-success {
|
| 380 |
+
background: var(--accent-green);
|
| 381 |
+
color: white;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.badge-warning {
|
| 385 |
+
background: var(--accent-orange);
|
| 386 |
+
color: white;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.badge-danger {
|
| 390 |
+
background: var(--accent-red);
|
| 391 |
+
color: white;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.badge-info {
|
| 395 |
+
background: var(--accent-blue);
|
| 396 |
+
color: white;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* Responsive Design */
|
| 400 |
+
@media (max-width: 768px) {
|
| 401 |
+
.main .block-container {
|
| 402 |
+
padding: var(--space-4) var(--space-2);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.status-grid {
|
| 406 |
+
grid-template-columns: 1fr;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.hero-section {
|
| 410 |
+
padding: var(--space-6);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
h1 {
|
| 414 |
+
font-size: var(--font-size-2xl);
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
/* Loading States */
|
| 419 |
+
.loading {
|
| 420 |
+
display: inline-block;
|
| 421 |
+
width: 20px;
|
| 422 |
+
height: 20px;
|
| 423 |
+
border: 3px solid var(--gray-600);
|
| 424 |
+
border-radius: 50%;
|
| 425 |
+
border-top-color: var(--primary-purple);
|
| 426 |
+
animation: spin 1s ease-in-out infinite;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
@keyframes spin {
|
| 430 |
+
to { transform: rotate(360deg); }
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* Animations */
|
| 434 |
+
.fade-in {
|
| 435 |
+
animation: fadeIn 0.5s ease-in;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
@keyframes fadeIn {
|
| 439 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 440 |
+
to { opacity: 1; transform: translateY(0); }
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.slide-in {
|
| 444 |
+
animation: slideIn 0.3s ease-out;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
@keyframes slideIn {
|
| 448 |
+
from { transform: translateX(-100%); }
|
| 449 |
+
to { transform: translateX(0); }
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/* Accessibility */
|
| 453 |
+
.sr-only {
|
| 454 |
+
position: absolute;
|
| 455 |
+
width: 1px;
|
| 456 |
+
height: 1px;
|
| 457 |
+
padding: 0;
|
| 458 |
+
margin: -1px;
|
| 459 |
+
overflow: hidden;
|
| 460 |
+
clip: rect(0, 0, 0, 0);
|
| 461 |
+
white-space: nowrap;
|
| 462 |
+
border: 0;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/* Focus states for accessibility */
|
| 466 |
+
button:focus,
|
| 467 |
+
input:focus,
|
| 468 |
+
select:focus,
|
| 469 |
+
textarea:focus {
|
| 470 |
+
outline: 2px solid var(--primary-purple);
|
| 471 |
+
outline-offset: 2px;
|
| 472 |
+
}
|
| 473 |
+
</style>
|
| 474 |
+
"""
|
| 475 |
+
|
| 476 |
+
@staticmethod
|
| 477 |
+
def create_hero_section(title: str, subtitle: str, user_email: str = None) -> str:
|
| 478 |
+
"""Create a Laurier-style hero section"""
|
| 479 |
+
if user_email:
|
| 480 |
+
return f"""
|
| 481 |
+
<div class="hero-section fade-in">
|
| 482 |
+
<div style="position: relative; z-index: 2;">
|
| 483 |
+
<h1 style="font-size: 2.5rem; margin-bottom: var(--space-4); color: white;">💜💛 {title}</h1>
|
| 484 |
+
<p style="font-size: 1.25rem; margin-bottom: var(--space-6); color: white; opacity: 0.9;">{subtitle}</p>
|
| 485 |
+
<div style="margin-top: var(--space-6); padding: var(--space-5); background: rgba(255,255,255,0.15); border-radius: var(--radius-lg); backdrop-filter: blur(10px);">
|
| 486 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8; color: white;">Welcome,</div>
|
| 487 |
+
<div style="font-weight: 800; font-size: var(--font-size-xl); margin: var(--space-2) 0; color: white;">{user_email}</div>
|
| 488 |
+
<div style="font-size: var(--font-size-sm); opacity: 0.8; color: white;">Thank you for showing up for the community today. 💜💛</div>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
"""
|
| 493 |
+
else:
|
| 494 |
+
return f"""
|
| 495 |
+
<div class="hero-section fade-in">
|
| 496 |
+
<div style="position: relative; z-index: 2;">
|
| 497 |
+
<h1 style="font-size: 2.5rem; margin-bottom: var(--space-4); color: white;">💜💛 {title}</h1>
|
| 498 |
+
<p style="font-size: 1.25rem; color: white; opacity: 0.9;">{subtitle}</p>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
"""
|
| 502 |
+
|
| 503 |
+
@staticmethod
|
| 504 |
+
def create_status_cards(data: Dict[str, Any]) -> str:
|
| 505 |
+
"""Create modern status cards"""
|
| 506 |
+
cards_html = '<div class="status-grid">'
|
| 507 |
+
|
| 508 |
+
for key, value in data.items():
|
| 509 |
+
if key == "shift_active":
|
| 510 |
+
cards_html += f"""
|
| 511 |
+
<div class="status-card slide-in">
|
| 512 |
+
<h4>Shift Active</h4>
|
| 513 |
+
<div class="value">{value}</div>
|
| 514 |
+
<div class="subtitle">since you signed in</div>
|
| 515 |
+
</div>
|
| 516 |
+
"""
|
| 517 |
+
elif key == "items_today":
|
| 518 |
+
cards_html += f"""
|
| 519 |
+
<div class="status-card slide-in">
|
| 520 |
+
<h4>Items Logged Today</h4>
|
| 521 |
+
<div class="value">{value}</div>
|
| 522 |
+
<div class="subtitle">items processed</div>
|
| 523 |
+
</div>
|
| 524 |
+
"""
|
| 525 |
+
elif key == "lifetime_hours":
|
| 526 |
+
cards_html += f"""
|
| 527 |
+
<div class="status-card slide-in">
|
| 528 |
+
<h4>Lifetime Hours</h4>
|
| 529 |
+
<div class="value">{value}</div>
|
| 530 |
+
<div class="subtitle">volunteer hours</div>
|
| 531 |
+
</div>
|
| 532 |
+
"""
|
| 533 |
+
|
| 534 |
+
cards_html += '</div>'
|
| 535 |
+
return cards_html
|
| 536 |
+
|
| 537 |
+
@staticmethod
|
| 538 |
+
def create_modern_form_section(title: str, description: str = None) -> str:
|
| 539 |
+
"""Create a modern form section header"""
|
| 540 |
+
desc_html = f'<p style="color: var(--gray-400); margin-bottom: var(--space-4);">{description}</p>' if description else ''
|
| 541 |
+
return f"""
|
| 542 |
+
<div class="modern-card fade-in">
|
| 543 |
+
<h3>{title}</h3>
|
| 544 |
+
{desc_html}
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
@staticmethod
|
| 548 |
+
def create_progress_indicator(current: int, total: int, label: str) -> str:
|
| 549 |
+
"""Create a modern progress indicator"""
|
| 550 |
+
percentage = (current / total * 100) if total > 0 else 0
|
| 551 |
+
return f"""
|
| 552 |
+
<div style="margin: var(--space-4) 0;">
|
| 553 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2);">
|
| 554 |
+
<span style="font-weight: 600; color: var(--gray-300);">{label}</span>
|
| 555 |
+
<span style="font-weight: 700; color: var(--primary-gold);">{current}/{total}</span>
|
| 556 |
+
</div>
|
| 557 |
+
<div class="progress-bar">
|
| 558 |
+
<div class="progress-fill" style="width: {percentage}%;"></div>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
"""
|
| 562 |
+
|
| 563 |
+
@staticmethod
|
| 564 |
+
def create_badge(text: str, variant: str = "info") -> str:
|
| 565 |
+
"""Create a modern badge"""
|
| 566 |
+
return f'<span class="badge badge-{variant}">{text}</span>'
|
| 567 |
+
|
| 568 |
+
@staticmethod
|
| 569 |
+
def create_loading_spinner() -> str:
|
| 570 |
+
"""Create a loading spinner"""
|
| 571 |
+
return '<div class="loading"></div>'
|
| 572 |
+
|
| 573 |
+
def apply_modern_ui():
|
| 574 |
+
"""Apply modern UI styling to the Streamlit app"""
|
| 575 |
+
st.markdown(ModernUIComponents.get_modern_css(), unsafe_allow_html=True)
|
| 576 |
+
|
| 577 |
+
def create_modern_layout():
|
| 578 |
+
"""Create a modern layout structure"""
|
| 579 |
+
return {
|
| 580 |
+
"container_style": "max-width: 1200px; margin: 0 auto;",
|
| 581 |
+
"sidebar_style": "background: var(--secondary-dark); border-right: 1px solid var(--gray-700);",
|
| 582 |
+
"main_style": "background: var(--primary-dark); padding: var(--space-6);"
|
| 583 |
+
}
|
backups/pre_test_backup_20250923_202554/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_test_backup_20250923_202554/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_test_backup_20250923_202554/streamlit_app.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Delightful Laurier-themed UX + enterprise data hygiene
|
| 3 |
+
# - OTP email login (Supabase)
|
| 4 |
+
# - Shift tracking & idle/8pm auto sign-out
|
| 5 |
+
# - Human-friendly visit_code (with DB trigger or safe fallback)
|
| 6 |
+
# - VLM-assisted item name
|
| 7 |
+
# - RPC ingest with validation/quarantine (fallback to direct insert)
|
| 8 |
+
# - Volunteer Impact card (today + lifetime)
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os, io, time, base64, re, uuid, json
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import pytz
|
| 17 |
+
import requests
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import streamlit as st
|
| 20 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 21 |
+
from supabase import create_client, Client
|
| 22 |
+
|
| 23 |
+
# ------------------------ App config ------------------------
|
| 24 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 25 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 26 |
+
|
| 27 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 28 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 29 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 30 |
+
|
| 31 |
+
def local_now() -> datetime:
|
| 32 |
+
return datetime.now(pytz.timezone(TZ))
|
| 33 |
+
|
| 34 |
+
st.set_page_config(page_title="Care Count", layout="centered")
|
| 35 |
+
st.markdown("""
|
| 36 |
+
<style>
|
| 37 |
+
/* Laurier theme vibes */
|
| 38 |
+
:root { --cc-purple:#6d28d9; --cc-gold:#fde047; --cc-bg:#0b1420; --cc-panel:#0f1a2a; --cc-border:#1d2a44; }
|
| 39 |
+
.block-container { padding-top: 2rem; }
|
| 40 |
+
h1, h2, .stMarkdown h1, .stMarkdown h2 { letter-spacing:.2px }
|
| 41 |
+
.cc-pill { display:inline-block; padding:4px 10px; border-radius:999px; background:var(--cc-gold); color:#111827; font-weight:700; font-size:12px; }
|
| 42 |
+
.cc-hint { background:#10233b; border:1px solid #1f3b5b; color:#e6e8f0; padding:12px 16px; border-radius:10px; }
|
| 43 |
+
.cc-hero { background:#0f1a2a; border:1px solid #1f2a44; padding:16px 18px; border-radius:14px; }
|
| 44 |
+
.cc-btn-primary button { background:var(--cc-purple)!important; color:#fff!important; border:0!important; }
|
| 45 |
+
.cc-danger button { background:#7f1d1d!important; color:#fff!important; }
|
| 46 |
+
.status-card { display:flex; gap:16px; flex-wrap:wrap; }
|
| 47 |
+
.card { background:#0f1a2a; border:1px solid #1f2a44; border-radius:14px; padding:14px 16px; min-width:200px; }
|
| 48 |
+
.card h4 { margin:0 0 6px 0; font-size:0.95rem; color:#cbd5e1 }
|
| 49 |
+
.card .big { font-size:1.6rem; font-weight:700; color:#e5e7eb }
|
| 50 |
+
.small { color:#9aa3b2; font-size:12px }
|
| 51 |
+
</style>
|
| 52 |
+
""", unsafe_allow_html=True)
|
| 53 |
+
|
| 54 |
+
st.title("💜💛 Care Count")
|
| 55 |
+
st.caption("Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time.")
|
| 56 |
+
|
| 57 |
+
# ------------------------ Secrets & client ------------------------
|
| 58 |
+
# Robust env/secrets helpers that work with Streamlit (st.secrets) and .env/CI.
|
| 59 |
+
# Order of precedence: OS env > Streamlit secrets > default.
|
| 60 |
+
|
| 61 |
+
# Optional: allow plain `python` runs to pick up a local .env if present
|
| 62 |
+
try:
|
| 63 |
+
from dotenv import load_dotenv # pip install python-dotenv (optional)
|
| 64 |
+
load_dotenv()
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Make Streamlit secrets safe to access even when not launched via `streamlit run`
|
| 69 |
+
try:
|
| 70 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 71 |
+
except Exception:
|
| 72 |
+
_SECRETS = {}
|
| 73 |
+
|
| 74 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 75 |
+
return val is not None and str(val).strip() != ""
|
| 76 |
+
|
| 77 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 78 |
+
"""
|
| 79 |
+
Flat TOML usage:
|
| 80 |
+
SUPABASE_URL = "..."
|
| 81 |
+
SUPABASE_KEY = "..."
|
| 82 |
+
Looks in OS env first, then st.secrets, else returns default.
|
| 83 |
+
"""
|
| 84 |
+
# 1) OS env
|
| 85 |
+
v = os.getenv(name)
|
| 86 |
+
if _is_useful(v):
|
| 87 |
+
return v
|
| 88 |
+
|
| 89 |
+
# 2) Streamlit secrets (flat)
|
| 90 |
+
try:
|
| 91 |
+
if hasattr(_SECRETS, "get"):
|
| 92 |
+
v = _SECRETS.get(name, default)
|
| 93 |
+
if _is_useful(v):
|
| 94 |
+
return v
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
# 3) default
|
| 99 |
+
return default
|
| 100 |
+
|
| 101 |
+
def require_secret(name: str) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Same as get_secret but fails fast with a clear error if missing/blank.
|
| 104 |
+
"""
|
| 105 |
+
v = get_secret(name, None)
|
| 106 |
+
if not _is_useful(v):
|
| 107 |
+
st.error(
|
| 108 |
+
f"Missing secret: {name}. "
|
| 109 |
+
"Ensure it exists as an environment variable or in .streamlit/secrets.toml"
|
| 110 |
+
)
|
| 111 |
+
st.stop()
|
| 112 |
+
return str(v)
|
| 113 |
+
|
| 114 |
+
# (Optional) lightweight visibility check during setup. Comment out in prod.
|
| 115 |
+
# st.write("Secrets present:",
|
| 116 |
+
# bool(get_secret("SUPABASE_URL")),
|
| 117 |
+
# bool(get_secret("SUPABASE_KEY")))
|
| 118 |
+
|
| 119 |
+
# Required Supabase credentials (flat keys)
|
| 120 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 121 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY") # anon key (RLS should be ON)
|
| 122 |
+
|
| 123 |
+
# Initialize Supabase client
|
| 124 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 125 |
+
|
| 126 |
+
# Optional provider/config (all flat keys with safe defaults)
|
| 127 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 128 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 129 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 130 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 131 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 132 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
st.caption(f"Provider: `{PROVIDER}` · Model: `{GEMMA_MODEL}` · TZ: `{TZ}`")
|
| 137 |
+
|
| 138 |
+
# ------------------------ Light events/audit (non-blocking) ------------------------
|
| 139 |
+
def log_event(action: str, actor: Optional[str], details: dict):
|
| 140 |
+
try:
|
| 141 |
+
sb.table("events").insert({"actor_email": actor, "action": action, "details": details}).execute()
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
# ------------------------ Auth (Email OTP) ------------------------
|
| 146 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 147 |
+
if "auth_email" not in st.session_state:
|
| 148 |
+
st.session_state["auth_email"] = None
|
| 149 |
+
if "user_email" in st.session_state:
|
| 150 |
+
return True, st.session_state["user_email"]
|
| 151 |
+
|
| 152 |
+
st.subheader("Sign in")
|
| 153 |
+
with st.form("otp_request", clear_on_submit=False, border=False):
|
| 154 |
+
email = st.text_input("Email", value=st.session_state.get("auth_email") or "", autocomplete="email")
|
| 155 |
+
send = st.form_submit_button("Send login code")
|
| 156 |
+
if send:
|
| 157 |
+
if not email or "@" not in email:
|
| 158 |
+
st.error("Please enter a valid email.")
|
| 159 |
+
else:
|
| 160 |
+
try:
|
| 161 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 162 |
+
st.session_state["auth_email"] = email
|
| 163 |
+
st.success("We emailed you a one-time code. Enter it to continue.")
|
| 164 |
+
except Exception as e:
|
| 165 |
+
st.error(f"Could not send code: {e}")
|
| 166 |
+
|
| 167 |
+
if st.session_state.get("auth_email"):
|
| 168 |
+
with st.form("otp_verify", clear_on_submit=True, border=False):
|
| 169 |
+
code = st.text_input("Enter 6-digit code", max_chars=6)
|
| 170 |
+
ok = st.form_submit_button("Verify & start shift")
|
| 171 |
+
if ok:
|
| 172 |
+
try:
|
| 173 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 174 |
+
if res and res.user:
|
| 175 |
+
email = st.session_state["auth_email"]
|
| 176 |
+
# Idempotent upsert for volunteer & start shift
|
| 177 |
+
sb.table("volunteers").upsert({
|
| 178 |
+
"email": email,
|
| 179 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 180 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 181 |
+
"shift_ended_at": None
|
| 182 |
+
}, on_conflict="email").execute()
|
| 183 |
+
st.session_state["user_email"] = email
|
| 184 |
+
st.session_state["shift_started"] = True
|
| 185 |
+
st.session_state["last_activity_at"] = local_now()
|
| 186 |
+
log_event("login", email, {"method":"otp"})
|
| 187 |
+
st.toast("Welcome back! Shift started. 💜", icon="✅")
|
| 188 |
+
return True, email
|
| 189 |
+
except Exception as e:
|
| 190 |
+
st.error(f"Verification failed: {e}")
|
| 191 |
+
return False, None
|
| 192 |
+
|
| 193 |
+
def end_shift(email: str, reason: str):
|
| 194 |
+
try:
|
| 195 |
+
sb.table("volunteers").update({"shift_ended_at": datetime.utcnow().isoformat()}).eq("email", email).execute()
|
| 196 |
+
log_event("shift_end", email, {"reason": reason})
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
def guard_cutoff_and_idle(email: str):
|
| 201 |
+
now = local_now()
|
| 202 |
+
last = st.session_state.get("last_activity_at")
|
| 203 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 204 |
+
end_shift(email, "inactivity")
|
| 205 |
+
st.session_state.clear()
|
| 206 |
+
st.info("You were logged out due to inactivity. Thank you for volunteering today!")
|
| 207 |
+
st.stop()
|
| 208 |
+
st.session_state["last_activity_at"] = now
|
| 209 |
+
|
| 210 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 211 |
+
if now >= cutoff:
|
| 212 |
+
end_shift(email, "cutoff_8pm")
|
| 213 |
+
st.session_state.clear()
|
| 214 |
+
st.info("We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 215 |
+
st.stop()
|
| 216 |
+
|
| 217 |
+
signed_in, user_email = auth_block()
|
| 218 |
+
if not signed_in:
|
| 219 |
+
st.stop()
|
| 220 |
+
guard_cutoff_and_idle(user_email)
|
| 221 |
+
|
| 222 |
+
# Welcome banner (visceral, kind)
|
| 223 |
+
st.markdown(
|
| 224 |
+
f"""<div class="cc-hero">
|
| 225 |
+
<div class="small">Welcome,</div>
|
| 226 |
+
<div style="font-weight:800;font-size:1.15rem"> {user_email}</div>
|
| 227 |
+
<div class="small">Thank you for showing up for the community today. 💜💛</div>
|
| 228 |
+
</div>""",
|
| 229 |
+
unsafe_allow_html=True
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Sign-out button (prominent)
|
| 233 |
+
c_signout = st.columns([1,1,6])[1]
|
| 234 |
+
with c_signout:
|
| 235 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 236 |
+
if st.button("🔒 Sign out"):
|
| 237 |
+
end_shift(user_email, "manual")
|
| 238 |
+
st.session_state.clear()
|
| 239 |
+
st.success("Signed out. See you next time!")
|
| 240 |
+
st.stop()
|
| 241 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 242 |
+
|
| 243 |
+
# ------------------------ Volunteer Impact card ------------------------
|
| 244 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 245 |
+
try:
|
| 246 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 247 |
+
except Exception:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
def items_today(email: str) -> int:
|
| 251 |
+
try:
|
| 252 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 253 |
+
# try partitioned table first
|
| 254 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 255 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 256 |
+
.eq("volunteer", email).execute().data
|
| 257 |
+
return len(data or [])
|
| 258 |
+
except Exception:
|
| 259 |
+
try:
|
| 260 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 261 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 262 |
+
.eq("volunteer", email).execute().data
|
| 263 |
+
return len(data or [])
|
| 264 |
+
except Exception:
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 268 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 269 |
+
lifetime_hours = vrow.get("total_hours") # may not exist yet; handled below
|
| 270 |
+
mins_active = 0
|
| 271 |
+
try:
|
| 272 |
+
if shift_started_at:
|
| 273 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 274 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
|
| 278 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 279 |
+
st.markdown(f'<div class="card"><h4>Shift active</h4><div class="big">{mins_active} min</div><div class="small">since you signed in</div></div>', unsafe_allow_html=True)
|
| 280 |
+
st.markdown(f'<div class="card"><h4>Items you logged today</h4><div class="big">{items_today(user_email)}</div></div>', unsafe_allow_html=True)
|
| 281 |
+
if isinstance(lifetime_hours, (int, float)):
|
| 282 |
+
st.markdown(f'<div class="card"><h4>Lifetime hours</h4><div class="big">{round(float(lifetime_hours),1)}</div></div>', unsafe_allow_html=True)
|
| 283 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 284 |
+
|
| 285 |
+
# ------------------------ Image helpers ------------------------
|
| 286 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 287 |
+
b = io.BytesIO(); img.save(b, format="PNG"); return b.getvalue()
|
| 288 |
+
|
| 289 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 290 |
+
img = img.convert("RGB")
|
| 291 |
+
w, h = img.size
|
| 292 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 293 |
+
if scale < 1.0: img = img.resize((int(w * scale), int(h * scale)))
|
| 294 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 295 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 296 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 297 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 298 |
+
return img
|
| 299 |
+
|
| 300 |
+
# ------------------------ VLM client ------------------------
|
| 301 |
+
SYSTEM_HINT = "You label item being held in the image for a food bank. Return ONLY the item name."
|
| 302 |
+
|
| 303 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 304 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 305 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 306 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 307 |
+
payload = {
|
| 308 |
+
"model": model_id,
|
| 309 |
+
"temperature": 0,
|
| 310 |
+
"messages": [
|
| 311 |
+
{"role": "system", "content": SYSTEM_HINT},
|
| 312 |
+
{"role": "user", "content": [
|
| 313 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 314 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 315 |
+
]}
|
| 316 |
+
]
|
| 317 |
+
}
|
| 318 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 319 |
+
if r.status_code != 200:
|
| 320 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 321 |
+
data = r.json()
|
| 322 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 323 |
+
|
| 324 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 325 |
+
if PROVIDER == "nebius":
|
| 326 |
+
if not NEBIUS_API_KEY: raise RuntimeError("NEBIUS_API_KEY missing")
|
| 327 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 328 |
+
elif PROVIDER == "featherless":
|
| 329 |
+
if not FEATH_API_KEY: raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 330 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 331 |
+
else:
|
| 332 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 333 |
+
|
| 334 |
+
# ------------------------ Normalization ------------------------
|
| 335 |
+
BRANDS = {
|
| 336 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 337 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 338 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 339 |
+
}
|
| 340 |
+
GENERIC_TYPES = {
|
| 341 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 342 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 343 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 344 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 345 |
+
}
|
| 346 |
+
def normalize_item_name(s: str) -> str:
|
| 347 |
+
s = (s or "").strip()
|
| 348 |
+
if not s: return ""
|
| 349 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 350 |
+
for b in BRANDS: low = low.replace(b, "")
|
| 351 |
+
chosen = None
|
| 352 |
+
for t in GENERIC_TYPES:
|
| 353 |
+
if t in low: chosen = t; break
|
| 354 |
+
cleaned = " ".join(low.split())
|
| 355 |
+
return (chosen or cleaned.title())[:120]
|
| 356 |
+
|
| 357 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 358 |
+
if not v: return None
|
| 359 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 360 |
+
return v[:maxlen] if v else None
|
| 361 |
+
|
| 362 |
+
# ------------------------ Visit flow ------------------------
|
| 363 |
+
st.subheader("🪪 Anonymous Student Visit")
|
| 364 |
+
|
| 365 |
+
active_visit = st.session_state.get("active_visit")
|
| 366 |
+
|
| 367 |
+
def fallback_visit_code() -> str:
|
| 368 |
+
"""Readable visit_code if DB trigger isn't present."""
|
| 369 |
+
try:
|
| 370 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 371 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 372 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 373 |
+
seq = len(todays) + 1
|
| 374 |
+
except Exception:
|
| 375 |
+
seq = int(time.time()) % 1000
|
| 376 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 377 |
+
|
| 378 |
+
c1, c2, c3 = st.columns(3)
|
| 379 |
+
with c1:
|
| 380 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 381 |
+
if not active_visit and st.button("Start Visit"):
|
| 382 |
+
try:
|
| 383 |
+
payload = {
|
| 384 |
+
"visit_code": fallback_visit_code(), # DB trigger will override if present
|
| 385 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 386 |
+
"ended_at": None,
|
| 387 |
+
"created_by": user_email
|
| 388 |
+
}
|
| 389 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 390 |
+
# If trigger generated code, keep that
|
| 391 |
+
if not v.get("visit_code"):
|
| 392 |
+
v["visit_code"] = payload["visit_code"]
|
| 393 |
+
st.session_state["active_visit"] = v
|
| 394 |
+
st.success(f"Visit #{v['id']} started · code: **{v['visit_code']}**")
|
| 395 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 396 |
+
except Exception as e:
|
| 397 |
+
st.error(f"Could not start visit: {e}")
|
| 398 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 399 |
+
|
| 400 |
+
with c2:
|
| 401 |
+
if active_visit and st.button("End Visit (Checkout)"):
|
| 402 |
+
try:
|
| 403 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 404 |
+
.eq("id", active_visit["id"]).execute()
|
| 405 |
+
st.success("Visit checked out. Ready for the next student.")
|
| 406 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 407 |
+
st.session_state.pop("active_visit", None)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
st.error(f"Could not end visit: {e}")
|
| 410 |
+
|
| 411 |
+
with c3:
|
| 412 |
+
if st.session_state.get("active_visit"):
|
| 413 |
+
v = st.session_state["active_visit"]
|
| 414 |
+
st.caption(f"Active visit_id: {v['id']} · code: {v.get('visit_code','')}")
|
| 415 |
+
else:
|
| 416 |
+
st.caption("No active visit.")
|
| 417 |
+
|
| 418 |
+
# ------------------------ Identify item ------------------------
|
| 419 |
+
st.subheader("📸 Identify item from image")
|
| 420 |
+
|
| 421 |
+
c4, c5 = st.columns(2)
|
| 422 |
+
with c4: cam = st.camera_input("Use your phone or webcam")
|
| 423 |
+
with c5: up = st.file_uploader("…or upload an image", type=["png","jpg","jpeg"])
|
| 424 |
+
|
| 425 |
+
img_file = cam or up
|
| 426 |
+
if img_file:
|
| 427 |
+
img = Image.open(img_file).convert("RGB")
|
| 428 |
+
st.image(img, use_container_width=True)
|
| 429 |
+
if st.button("🔍 Ask model for item name"):
|
| 430 |
+
t0, raw = time.time(), ""
|
| 431 |
+
try:
|
| 432 |
+
pre = preprocess_for_label(img); raw = gemma_item_name(_to_png_bytes(pre))
|
| 433 |
+
except Exception as e:
|
| 434 |
+
st.error(f"Provider error: {e}"); log_event("vlm_error", user_email, {"error": str(e)})
|
| 435 |
+
norm = normalize_item_name(raw)
|
| 436 |
+
if raw: st.success(f"🧠 Model: **{raw}**")
|
| 437 |
+
st.info(f"✨ Normalized: **{norm or '(unknown)'}** · ⏱️ {time.time()-t0:.2f}s")
|
| 438 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 439 |
+
st.session_state["last_activity_at"] = local_now()
|
| 440 |
+
|
| 441 |
+
# ------------------------ Log item ------------------------
|
| 442 |
+
st.subheader("📬 Log item to current visit")
|
| 443 |
+
|
| 444 |
+
item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name",""))
|
| 445 |
+
quantity = st.number_input("Quantity", min_value=1, max_value=9999, step=1, value=1)
|
| 446 |
+
category = st.text_input("Category (optional)")
|
| 447 |
+
unit = st.text_input("Unit (optional, e.g., 500 mL, 1 L, 250 g)")
|
| 448 |
+
barcode = st.text_input("Barcode (optional)")
|
| 449 |
+
|
| 450 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 451 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 452 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 453 |
+
|
| 454 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 455 |
+
category: Optional[str], unit: Optional[str],
|
| 456 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 457 |
+
"""
|
| 458 |
+
Call the RPC safe_ingest_visit_item if it exists.
|
| 459 |
+
Returns (ok, msg). If function is missing, raises to trigger fallback.
|
| 460 |
+
"""
|
| 461 |
+
try:
|
| 462 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 463 |
+
"p_email": email,
|
| 464 |
+
"p_visit_id": v_id,
|
| 465 |
+
"p_item_name": name,
|
| 466 |
+
"p_qty": qty,
|
| 467 |
+
"p_category": category,
|
| 468 |
+
"p_unit": unit,
|
| 469 |
+
"p_barcode": barcode,
|
| 470 |
+
"p_ts": ts_iso,
|
| 471 |
+
"p_ingest_id": ingest_id
|
| 472 |
+
}).execute()
|
| 473 |
+
# RPC returns a setof (ok boolean, msg text) — normalize
|
| 474 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 475 |
+
if rows:
|
| 476 |
+
r0 = rows[0]
|
| 477 |
+
ok = bool(r0.get("ok", False))
|
| 478 |
+
msg = str(r0.get("msg", ""))
|
| 479 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 480 |
+
# Some PostgREST setups return {} — treat as ok
|
| 481 |
+
return True, "ok"
|
| 482 |
+
except Exception as e:
|
| 483 |
+
# If function missing (42883) or not exposed, re-raise to use fallback
|
| 484 |
+
raise e
|
| 485 |
+
|
| 486 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 487 |
+
category: Optional[str], unit: Optional[str],
|
| 488 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 489 |
+
"""Write into visit_items_p if present, else visit_items (keeps app working)."""
|
| 490 |
+
payload = {
|
| 491 |
+
"visit_id": v_id,
|
| 492 |
+
"timestamp": ts_iso,
|
| 493 |
+
"volunteer": email,
|
| 494 |
+
"item_name": name,
|
| 495 |
+
"category": category,
|
| 496 |
+
"unit": unit,
|
| 497 |
+
"qty": qty,
|
| 498 |
+
"barcode": barcode,
|
| 499 |
+
"weather_type": None,
|
| 500 |
+
"temp_c": None,
|
| 501 |
+
"ingest_id": ingest_id
|
| 502 |
+
}
|
| 503 |
+
try:
|
| 504 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 505 |
+
except Exception:
|
| 506 |
+
# legacy table fallback
|
| 507 |
+
payload.pop("ingest_id", None)
|
| 508 |
+
sb.table("visit_items").insert(payload).execute()
|
| 509 |
+
|
| 510 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 511 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 512 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled):
|
| 513 |
+
v = st.session_state.get("active_visit")
|
| 514 |
+
if not v:
|
| 515 |
+
st.warning("Start a visit first, then save items.")
|
| 516 |
+
else:
|
| 517 |
+
name_clean = clean_text(item_name, 120)
|
| 518 |
+
if not name_clean:
|
| 519 |
+
st.warning("Item name is required.")
|
| 520 |
+
else:
|
| 521 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 522 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 523 |
+
try:
|
| 524 |
+
ok, msg = try_rpc_ingest(
|
| 525 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 526 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 527 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 528 |
+
)
|
| 529 |
+
if ok:
|
| 530 |
+
st.success("Item logged ✅")
|
| 531 |
+
else:
|
| 532 |
+
st.warning(f"Ingest said: {msg}. (Will try direct insert.)")
|
| 533 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 534 |
+
clean_text(category,80), clean_text(unit,40),
|
| 535 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 536 |
+
st.success("Item logged ✅ (fallback)")
|
| 537 |
+
except Exception as e:
|
| 538 |
+
# RPC missing or failed → direct insert
|
| 539 |
+
try:
|
| 540 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 541 |
+
clean_text(category,80), clean_text(unit,40),
|
| 542 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 543 |
+
st.success("Item logged ✅ (fallback)")
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"Ingest failed: {e2}")
|
| 546 |
+
st.session_state["last_activity_at"] = local_now()
|
| 547 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 548 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 549 |
+
|
| 550 |
+
# ------------------------ Visit items view + delete ------------------------
|
| 551 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 552 |
+
# Prefer partitioned table
|
| 553 |
+
try:
|
| 554 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 555 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 556 |
+
except Exception:
|
| 557 |
+
try:
|
| 558 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 559 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 560 |
+
except Exception:
|
| 561 |
+
return []
|
| 562 |
+
|
| 563 |
+
def delete_item(table: str, item_id: int):
|
| 564 |
+
try:
|
| 565 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 566 |
+
except Exception as e:
|
| 567 |
+
raise e
|
| 568 |
+
|
| 569 |
+
if st.session_state.get("active_visit"):
|
| 570 |
+
st.subheader("🧾 Items in this visit")
|
| 571 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 572 |
+
if rows:
|
| 573 |
+
df = pd.DataFrame(rows)
|
| 574 |
+
st.dataframe(df, use_container_width=True)
|
| 575 |
+
with st.expander("🗑️ Delete an item (if mis-logged)"):
|
| 576 |
+
ids = [r["id"] for r in rows if "id" in r]
|
| 577 |
+
choice = st.selectbox("Choose item id", ids) if ids else None
|
| 578 |
+
st.markdown('<div class="cc-danger">', unsafe_allow_html=True)
|
| 579 |
+
if st.button("Delete selected", disabled=not bool(ids)):
|
| 580 |
+
if choice is None:
|
| 581 |
+
st.warning("Pick an id.")
|
| 582 |
+
else:
|
| 583 |
+
# try both tables safely
|
| 584 |
+
try:
|
| 585 |
+
delete_item("visit_items_p", int(choice))
|
| 586 |
+
except Exception:
|
| 587 |
+
delete_item("visit_items", int(choice))
|
| 588 |
+
st.success("Deleted.")
|
| 589 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 590 |
+
else:
|
| 591 |
+
st.caption("No items logged for this visit yet.")
|
| 592 |
+
|
| 593 |
+
# ------------------------ Analytics (light) ------------------------
|
| 594 |
+
st.subheader("📈 Today")
|
| 595 |
+
try:
|
| 596 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 597 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 598 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 599 |
+
visits = int(today["visits"]) if today and "visits" in today else 0
|
| 600 |
+
items = int(today["items"]) if today and "items" in today else 0
|
| 601 |
+
st.markdown(f"**Visits:** {visits} · **Items:** {items}")
|
| 602 |
+
except Exception:
|
| 603 |
+
st.caption("Analytics view unavailable yet.")
|
| 604 |
+
|
| 605 |
+
# ------------------------ Gentle reminder ------------------------
|
| 606 |
+
st.markdown(
|
| 607 |
+
"""<div class="cc-hint">
|
| 608 |
+
💡 When you’re done, please <b>End Visit</b> and <b>Sign out</b>.<br>
|
| 609 |
+
We’ll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 610 |
+
</div>""",
|
| 611 |
+
unsafe_allow_html=True
|
| 612 |
+
)
|
backups/pre_test_backup_20250923_202613/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_test_backup_20250923_202613/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_test_backup_20250923_202613/streamlit_app.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Delightful Laurier-themed UX + enterprise data hygiene
|
| 3 |
+
# - OTP email login (Supabase)
|
| 4 |
+
# - Shift tracking & idle/8pm auto sign-out
|
| 5 |
+
# - Human-friendly visit_code (with DB trigger or safe fallback)
|
| 6 |
+
# - VLM-assisted item name
|
| 7 |
+
# - RPC ingest with validation/quarantine (fallback to direct insert)
|
| 8 |
+
# - Volunteer Impact card (today + lifetime)
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os, io, time, base64, re, uuid, json
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import pytz
|
| 17 |
+
import requests
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import streamlit as st
|
| 20 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 21 |
+
from supabase import create_client, Client
|
| 22 |
+
|
| 23 |
+
# ------------------------ App config ------------------------
|
| 24 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 25 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 26 |
+
|
| 27 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 28 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 29 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 30 |
+
|
| 31 |
+
def local_now() -> datetime:
|
| 32 |
+
return datetime.now(pytz.timezone(TZ))
|
| 33 |
+
|
| 34 |
+
st.set_page_config(page_title="Care Count", layout="centered")
|
| 35 |
+
st.markdown("""
|
| 36 |
+
<style>
|
| 37 |
+
/* Laurier theme vibes */
|
| 38 |
+
:root { --cc-purple:#6d28d9; --cc-gold:#fde047; --cc-bg:#0b1420; --cc-panel:#0f1a2a; --cc-border:#1d2a44; }
|
| 39 |
+
.block-container { padding-top: 2rem; }
|
| 40 |
+
h1, h2, .stMarkdown h1, .stMarkdown h2 { letter-spacing:.2px }
|
| 41 |
+
.cc-pill { display:inline-block; padding:4px 10px; border-radius:999px; background:var(--cc-gold); color:#111827; font-weight:700; font-size:12px; }
|
| 42 |
+
.cc-hint { background:#10233b; border:1px solid #1f3b5b; color:#e6e8f0; padding:12px 16px; border-radius:10px; }
|
| 43 |
+
.cc-hero { background:#0f1a2a; border:1px solid #1f2a44; padding:16px 18px; border-radius:14px; }
|
| 44 |
+
.cc-btn-primary button { background:var(--cc-purple)!important; color:#fff!important; border:0!important; }
|
| 45 |
+
.cc-danger button { background:#7f1d1d!important; color:#fff!important; }
|
| 46 |
+
.status-card { display:flex; gap:16px; flex-wrap:wrap; }
|
| 47 |
+
.card { background:#0f1a2a; border:1px solid #1f2a44; border-radius:14px; padding:14px 16px; min-width:200px; }
|
| 48 |
+
.card h4 { margin:0 0 6px 0; font-size:0.95rem; color:#cbd5e1 }
|
| 49 |
+
.card .big { font-size:1.6rem; font-weight:700; color:#e5e7eb }
|
| 50 |
+
.small { color:#9aa3b2; font-size:12px }
|
| 51 |
+
</style>
|
| 52 |
+
""", unsafe_allow_html=True)
|
| 53 |
+
|
| 54 |
+
st.title("💜💛 Care Count")
|
| 55 |
+
st.caption("Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time.")
|
| 56 |
+
|
| 57 |
+
# ------------------------ Secrets & client ------------------------
|
| 58 |
+
# Robust env/secrets helpers that work with Streamlit (st.secrets) and .env/CI.
|
| 59 |
+
# Order of precedence: OS env > Streamlit secrets > default.
|
| 60 |
+
|
| 61 |
+
# Optional: allow plain `python` runs to pick up a local .env if present
|
| 62 |
+
try:
|
| 63 |
+
from dotenv import load_dotenv # pip install python-dotenv (optional)
|
| 64 |
+
load_dotenv()
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Make Streamlit secrets safe to access even when not launched via `streamlit run`
|
| 69 |
+
try:
|
| 70 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 71 |
+
except Exception:
|
| 72 |
+
_SECRETS = {}
|
| 73 |
+
|
| 74 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 75 |
+
return val is not None and str(val).strip() != ""
|
| 76 |
+
|
| 77 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 78 |
+
"""
|
| 79 |
+
Flat TOML usage:
|
| 80 |
+
SUPABASE_URL = "..."
|
| 81 |
+
SUPABASE_KEY = "..."
|
| 82 |
+
Looks in OS env first, then st.secrets, else returns default.
|
| 83 |
+
"""
|
| 84 |
+
# 1) OS env
|
| 85 |
+
v = os.getenv(name)
|
| 86 |
+
if _is_useful(v):
|
| 87 |
+
return v
|
| 88 |
+
|
| 89 |
+
# 2) Streamlit secrets (flat)
|
| 90 |
+
try:
|
| 91 |
+
if hasattr(_SECRETS, "get"):
|
| 92 |
+
v = _SECRETS.get(name, default)
|
| 93 |
+
if _is_useful(v):
|
| 94 |
+
return v
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
# 3) default
|
| 99 |
+
return default
|
| 100 |
+
|
| 101 |
+
def require_secret(name: str) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Same as get_secret but fails fast with a clear error if missing/blank.
|
| 104 |
+
"""
|
| 105 |
+
v = get_secret(name, None)
|
| 106 |
+
if not _is_useful(v):
|
| 107 |
+
st.error(
|
| 108 |
+
f"Missing secret: {name}. "
|
| 109 |
+
"Ensure it exists as an environment variable or in .streamlit/secrets.toml"
|
| 110 |
+
)
|
| 111 |
+
st.stop()
|
| 112 |
+
return str(v)
|
| 113 |
+
|
| 114 |
+
# (Optional) lightweight visibility check during setup. Comment out in prod.
|
| 115 |
+
# st.write("Secrets present:",
|
| 116 |
+
# bool(get_secret("SUPABASE_URL")),
|
| 117 |
+
# bool(get_secret("SUPABASE_KEY")))
|
| 118 |
+
|
| 119 |
+
# Required Supabase credentials (flat keys)
|
| 120 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 121 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY") # anon key (RLS should be ON)
|
| 122 |
+
|
| 123 |
+
# Initialize Supabase client
|
| 124 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 125 |
+
|
| 126 |
+
# Optional provider/config (all flat keys with safe defaults)
|
| 127 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 128 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 129 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 130 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 131 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 132 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
st.caption(f"Provider: `{PROVIDER}` · Model: `{GEMMA_MODEL}` · TZ: `{TZ}`")
|
| 137 |
+
|
| 138 |
+
# ------------------------ Light events/audit (non-blocking) ------------------------
|
| 139 |
+
def log_event(action: str, actor: Optional[str], details: dict):
|
| 140 |
+
try:
|
| 141 |
+
sb.table("events").insert({"actor_email": actor, "action": action, "details": details}).execute()
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
# ------------------------ Auth (Email OTP) ------------------------
|
| 146 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 147 |
+
if "auth_email" not in st.session_state:
|
| 148 |
+
st.session_state["auth_email"] = None
|
| 149 |
+
if "user_email" in st.session_state:
|
| 150 |
+
return True, st.session_state["user_email"]
|
| 151 |
+
|
| 152 |
+
st.subheader("Sign in")
|
| 153 |
+
with st.form("otp_request", clear_on_submit=False, border=False):
|
| 154 |
+
email = st.text_input("Email", value=st.session_state.get("auth_email") or "", autocomplete="email")
|
| 155 |
+
send = st.form_submit_button("Send login code")
|
| 156 |
+
if send:
|
| 157 |
+
if not email or "@" not in email:
|
| 158 |
+
st.error("Please enter a valid email.")
|
| 159 |
+
else:
|
| 160 |
+
try:
|
| 161 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 162 |
+
st.session_state["auth_email"] = email
|
| 163 |
+
st.success("We emailed you a one-time code. Enter it to continue.")
|
| 164 |
+
except Exception as e:
|
| 165 |
+
st.error(f"Could not send code: {e}")
|
| 166 |
+
|
| 167 |
+
if st.session_state.get("auth_email"):
|
| 168 |
+
with st.form("otp_verify", clear_on_submit=True, border=False):
|
| 169 |
+
code = st.text_input("Enter 6-digit code", max_chars=6)
|
| 170 |
+
ok = st.form_submit_button("Verify & start shift")
|
| 171 |
+
if ok:
|
| 172 |
+
try:
|
| 173 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 174 |
+
if res and res.user:
|
| 175 |
+
email = st.session_state["auth_email"]
|
| 176 |
+
# Idempotent upsert for volunteer & start shift
|
| 177 |
+
sb.table("volunteers").upsert({
|
| 178 |
+
"email": email,
|
| 179 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 180 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 181 |
+
"shift_ended_at": None
|
| 182 |
+
}, on_conflict="email").execute()
|
| 183 |
+
st.session_state["user_email"] = email
|
| 184 |
+
st.session_state["shift_started"] = True
|
| 185 |
+
st.session_state["last_activity_at"] = local_now()
|
| 186 |
+
log_event("login", email, {"method":"otp"})
|
| 187 |
+
st.toast("Welcome back! Shift started. 💜", icon="✅")
|
| 188 |
+
return True, email
|
| 189 |
+
except Exception as e:
|
| 190 |
+
st.error(f"Verification failed: {e}")
|
| 191 |
+
return False, None
|
| 192 |
+
|
| 193 |
+
def end_shift(email: str, reason: str):
|
| 194 |
+
try:
|
| 195 |
+
sb.table("volunteers").update({"shift_ended_at": datetime.utcnow().isoformat()}).eq("email", email).execute()
|
| 196 |
+
log_event("shift_end", email, {"reason": reason})
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
def guard_cutoff_and_idle(email: str):
|
| 201 |
+
now = local_now()
|
| 202 |
+
last = st.session_state.get("last_activity_at")
|
| 203 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 204 |
+
end_shift(email, "inactivity")
|
| 205 |
+
st.session_state.clear()
|
| 206 |
+
st.info("You were logged out due to inactivity. Thank you for volunteering today!")
|
| 207 |
+
st.stop()
|
| 208 |
+
st.session_state["last_activity_at"] = now
|
| 209 |
+
|
| 210 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 211 |
+
if now >= cutoff:
|
| 212 |
+
end_shift(email, "cutoff_8pm")
|
| 213 |
+
st.session_state.clear()
|
| 214 |
+
st.info("We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 215 |
+
st.stop()
|
| 216 |
+
|
| 217 |
+
signed_in, user_email = auth_block()
|
| 218 |
+
if not signed_in:
|
| 219 |
+
st.stop()
|
| 220 |
+
guard_cutoff_and_idle(user_email)
|
| 221 |
+
|
| 222 |
+
# Welcome banner (visceral, kind)
|
| 223 |
+
st.markdown(
|
| 224 |
+
f"""<div class="cc-hero">
|
| 225 |
+
<div class="small">Welcome,</div>
|
| 226 |
+
<div style="font-weight:800;font-size:1.15rem"> {user_email}</div>
|
| 227 |
+
<div class="small">Thank you for showing up for the community today. 💜💛</div>
|
| 228 |
+
</div>""",
|
| 229 |
+
unsafe_allow_html=True
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Sign-out button (prominent)
|
| 233 |
+
c_signout = st.columns([1,1,6])[1]
|
| 234 |
+
with c_signout:
|
| 235 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 236 |
+
if st.button("🔒 Sign out"):
|
| 237 |
+
end_shift(user_email, "manual")
|
| 238 |
+
st.session_state.clear()
|
| 239 |
+
st.success("Signed out. See you next time!")
|
| 240 |
+
st.stop()
|
| 241 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 242 |
+
|
| 243 |
+
# ------------------------ Volunteer Impact card ------------------------
|
| 244 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 245 |
+
try:
|
| 246 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 247 |
+
except Exception:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
def items_today(email: str) -> int:
|
| 251 |
+
try:
|
| 252 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 253 |
+
# try partitioned table first
|
| 254 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 255 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 256 |
+
.eq("volunteer", email).execute().data
|
| 257 |
+
return len(data or [])
|
| 258 |
+
except Exception:
|
| 259 |
+
try:
|
| 260 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 261 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 262 |
+
.eq("volunteer", email).execute().data
|
| 263 |
+
return len(data or [])
|
| 264 |
+
except Exception:
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 268 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 269 |
+
lifetime_hours = vrow.get("total_hours") # may not exist yet; handled below
|
| 270 |
+
mins_active = 0
|
| 271 |
+
try:
|
| 272 |
+
if shift_started_at:
|
| 273 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 274 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
|
| 278 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 279 |
+
st.markdown(f'<div class="card"><h4>Shift active</h4><div class="big">{mins_active} min</div><div class="small">since you signed in</div></div>', unsafe_allow_html=True)
|
| 280 |
+
st.markdown(f'<div class="card"><h4>Items you logged today</h4><div class="big">{items_today(user_email)}</div></div>', unsafe_allow_html=True)
|
| 281 |
+
if isinstance(lifetime_hours, (int, float)):
|
| 282 |
+
st.markdown(f'<div class="card"><h4>Lifetime hours</h4><div class="big">{round(float(lifetime_hours),1)}</div></div>', unsafe_allow_html=True)
|
| 283 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 284 |
+
|
| 285 |
+
# ------------------------ Image helpers ------------------------
|
| 286 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 287 |
+
b = io.BytesIO(); img.save(b, format="PNG"); return b.getvalue()
|
| 288 |
+
|
| 289 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 290 |
+
img = img.convert("RGB")
|
| 291 |
+
w, h = img.size
|
| 292 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 293 |
+
if scale < 1.0: img = img.resize((int(w * scale), int(h * scale)))
|
| 294 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 295 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 296 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 297 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 298 |
+
return img
|
| 299 |
+
|
| 300 |
+
# ------------------------ VLM client ------------------------
|
| 301 |
+
SYSTEM_HINT = "You label item being held in the image for a food bank. Return ONLY the item name."
|
| 302 |
+
|
| 303 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 304 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 305 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 306 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 307 |
+
payload = {
|
| 308 |
+
"model": model_id,
|
| 309 |
+
"temperature": 0,
|
| 310 |
+
"messages": [
|
| 311 |
+
{"role": "system", "content": SYSTEM_HINT},
|
| 312 |
+
{"role": "user", "content": [
|
| 313 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 314 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 315 |
+
]}
|
| 316 |
+
]
|
| 317 |
+
}
|
| 318 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 319 |
+
if r.status_code != 200:
|
| 320 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 321 |
+
data = r.json()
|
| 322 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 323 |
+
|
| 324 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 325 |
+
if PROVIDER == "nebius":
|
| 326 |
+
if not NEBIUS_API_KEY: raise RuntimeError("NEBIUS_API_KEY missing")
|
| 327 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 328 |
+
elif PROVIDER == "featherless":
|
| 329 |
+
if not FEATH_API_KEY: raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 330 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 331 |
+
else:
|
| 332 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 333 |
+
|
| 334 |
+
# ------------------------ Normalization ------------------------
|
| 335 |
+
BRANDS = {
|
| 336 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 337 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 338 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 339 |
+
}
|
| 340 |
+
GENERIC_TYPES = {
|
| 341 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 342 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 343 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 344 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 345 |
+
}
|
| 346 |
+
def normalize_item_name(s: str) -> str:
|
| 347 |
+
s = (s or "").strip()
|
| 348 |
+
if not s: return ""
|
| 349 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 350 |
+
for b in BRANDS: low = low.replace(b, "")
|
| 351 |
+
chosen = None
|
| 352 |
+
for t in GENERIC_TYPES:
|
| 353 |
+
if t in low: chosen = t; break
|
| 354 |
+
cleaned = " ".join(low.split())
|
| 355 |
+
return (chosen or cleaned.title())[:120]
|
| 356 |
+
|
| 357 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 358 |
+
if not v: return None
|
| 359 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 360 |
+
return v[:maxlen] if v else None
|
| 361 |
+
|
| 362 |
+
# ------------------------ Visit flow ------------------------
|
| 363 |
+
st.subheader("🪪 Anonymous Student Visit")
|
| 364 |
+
|
| 365 |
+
active_visit = st.session_state.get("active_visit")
|
| 366 |
+
|
| 367 |
+
def fallback_visit_code() -> str:
|
| 368 |
+
"""Readable visit_code if DB trigger isn't present."""
|
| 369 |
+
try:
|
| 370 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 371 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 372 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 373 |
+
seq = len(todays) + 1
|
| 374 |
+
except Exception:
|
| 375 |
+
seq = int(time.time()) % 1000
|
| 376 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 377 |
+
|
| 378 |
+
c1, c2, c3 = st.columns(3)
|
| 379 |
+
with c1:
|
| 380 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 381 |
+
if not active_visit and st.button("Start Visit"):
|
| 382 |
+
try:
|
| 383 |
+
payload = {
|
| 384 |
+
"visit_code": fallback_visit_code(), # DB trigger will override if present
|
| 385 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 386 |
+
"ended_at": None,
|
| 387 |
+
"created_by": user_email
|
| 388 |
+
}
|
| 389 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 390 |
+
# If trigger generated code, keep that
|
| 391 |
+
if not v.get("visit_code"):
|
| 392 |
+
v["visit_code"] = payload["visit_code"]
|
| 393 |
+
st.session_state["active_visit"] = v
|
| 394 |
+
st.success(f"Visit #{v['id']} started · code: **{v['visit_code']}**")
|
| 395 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 396 |
+
except Exception as e:
|
| 397 |
+
st.error(f"Could not start visit: {e}")
|
| 398 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 399 |
+
|
| 400 |
+
with c2:
|
| 401 |
+
if active_visit and st.button("End Visit (Checkout)"):
|
| 402 |
+
try:
|
| 403 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 404 |
+
.eq("id", active_visit["id"]).execute()
|
| 405 |
+
st.success("Visit checked out. Ready for the next student.")
|
| 406 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 407 |
+
st.session_state.pop("active_visit", None)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
st.error(f"Could not end visit: {e}")
|
| 410 |
+
|
| 411 |
+
with c3:
|
| 412 |
+
if st.session_state.get("active_visit"):
|
| 413 |
+
v = st.session_state["active_visit"]
|
| 414 |
+
st.caption(f"Active visit_id: {v['id']} · code: {v.get('visit_code','')}")
|
| 415 |
+
else:
|
| 416 |
+
st.caption("No active visit.")
|
| 417 |
+
|
| 418 |
+
# ------------------------ Identify item ------------------------
|
| 419 |
+
st.subheader("📸 Identify item from image")
|
| 420 |
+
|
| 421 |
+
c4, c5 = st.columns(2)
|
| 422 |
+
with c4: cam = st.camera_input("Use your phone or webcam")
|
| 423 |
+
with c5: up = st.file_uploader("…or upload an image", type=["png","jpg","jpeg"])
|
| 424 |
+
|
| 425 |
+
img_file = cam or up
|
| 426 |
+
if img_file:
|
| 427 |
+
img = Image.open(img_file).convert("RGB")
|
| 428 |
+
st.image(img, use_container_width=True)
|
| 429 |
+
if st.button("🔍 Ask model for item name"):
|
| 430 |
+
t0, raw = time.time(), ""
|
| 431 |
+
try:
|
| 432 |
+
pre = preprocess_for_label(img); raw = gemma_item_name(_to_png_bytes(pre))
|
| 433 |
+
except Exception as e:
|
| 434 |
+
st.error(f"Provider error: {e}"); log_event("vlm_error", user_email, {"error": str(e)})
|
| 435 |
+
norm = normalize_item_name(raw)
|
| 436 |
+
if raw: st.success(f"🧠 Model: **{raw}**")
|
| 437 |
+
st.info(f"✨ Normalized: **{norm or '(unknown)'}** · ⏱️ {time.time()-t0:.2f}s")
|
| 438 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 439 |
+
st.session_state["last_activity_at"] = local_now()
|
| 440 |
+
|
| 441 |
+
# ------------------------ Log item ------------------------
|
| 442 |
+
st.subheader("📬 Log item to current visit")
|
| 443 |
+
|
| 444 |
+
item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name",""))
|
| 445 |
+
quantity = st.number_input("Quantity", min_value=1, max_value=9999, step=1, value=1)
|
| 446 |
+
category = st.text_input("Category (optional)")
|
| 447 |
+
unit = st.text_input("Unit (optional, e.g., 500 mL, 1 L, 250 g)")
|
| 448 |
+
barcode = st.text_input("Barcode (optional)")
|
| 449 |
+
|
| 450 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 451 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 452 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 453 |
+
|
| 454 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 455 |
+
category: Optional[str], unit: Optional[str],
|
| 456 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 457 |
+
"""
|
| 458 |
+
Call the RPC safe_ingest_visit_item if it exists.
|
| 459 |
+
Returns (ok, msg). If function is missing, raises to trigger fallback.
|
| 460 |
+
"""
|
| 461 |
+
try:
|
| 462 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 463 |
+
"p_email": email,
|
| 464 |
+
"p_visit_id": v_id,
|
| 465 |
+
"p_item_name": name,
|
| 466 |
+
"p_qty": qty,
|
| 467 |
+
"p_category": category,
|
| 468 |
+
"p_unit": unit,
|
| 469 |
+
"p_barcode": barcode,
|
| 470 |
+
"p_ts": ts_iso,
|
| 471 |
+
"p_ingest_id": ingest_id
|
| 472 |
+
}).execute()
|
| 473 |
+
# RPC returns a setof (ok boolean, msg text) — normalize
|
| 474 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 475 |
+
if rows:
|
| 476 |
+
r0 = rows[0]
|
| 477 |
+
ok = bool(r0.get("ok", False))
|
| 478 |
+
msg = str(r0.get("msg", ""))
|
| 479 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 480 |
+
# Some PostgREST setups return {} — treat as ok
|
| 481 |
+
return True, "ok"
|
| 482 |
+
except Exception as e:
|
| 483 |
+
# If function missing (42883) or not exposed, re-raise to use fallback
|
| 484 |
+
raise e
|
| 485 |
+
|
| 486 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 487 |
+
category: Optional[str], unit: Optional[str],
|
| 488 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 489 |
+
"""Write into visit_items_p if present, else visit_items (keeps app working)."""
|
| 490 |
+
payload = {
|
| 491 |
+
"visit_id": v_id,
|
| 492 |
+
"timestamp": ts_iso,
|
| 493 |
+
"volunteer": email,
|
| 494 |
+
"item_name": name,
|
| 495 |
+
"category": category,
|
| 496 |
+
"unit": unit,
|
| 497 |
+
"qty": qty,
|
| 498 |
+
"barcode": barcode,
|
| 499 |
+
"weather_type": None,
|
| 500 |
+
"temp_c": None,
|
| 501 |
+
"ingest_id": ingest_id
|
| 502 |
+
}
|
| 503 |
+
try:
|
| 504 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 505 |
+
except Exception:
|
| 506 |
+
# legacy table fallback
|
| 507 |
+
payload.pop("ingest_id", None)
|
| 508 |
+
sb.table("visit_items").insert(payload).execute()
|
| 509 |
+
|
| 510 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 511 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 512 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled):
|
| 513 |
+
v = st.session_state.get("active_visit")
|
| 514 |
+
if not v:
|
| 515 |
+
st.warning("Start a visit first, then save items.")
|
| 516 |
+
else:
|
| 517 |
+
name_clean = clean_text(item_name, 120)
|
| 518 |
+
if not name_clean:
|
| 519 |
+
st.warning("Item name is required.")
|
| 520 |
+
else:
|
| 521 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 522 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 523 |
+
try:
|
| 524 |
+
ok, msg = try_rpc_ingest(
|
| 525 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 526 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 527 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 528 |
+
)
|
| 529 |
+
if ok:
|
| 530 |
+
st.success("Item logged ✅")
|
| 531 |
+
else:
|
| 532 |
+
st.warning(f"Ingest said: {msg}. (Will try direct insert.)")
|
| 533 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 534 |
+
clean_text(category,80), clean_text(unit,40),
|
| 535 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 536 |
+
st.success("Item logged ✅ (fallback)")
|
| 537 |
+
except Exception as e:
|
| 538 |
+
# RPC missing or failed → direct insert
|
| 539 |
+
try:
|
| 540 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 541 |
+
clean_text(category,80), clean_text(unit,40),
|
| 542 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 543 |
+
st.success("Item logged ✅ (fallback)")
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"Ingest failed: {e2}")
|
| 546 |
+
st.session_state["last_activity_at"] = local_now()
|
| 547 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 548 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 549 |
+
|
| 550 |
+
# ------------------------ Visit items view + delete ------------------------
|
| 551 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 552 |
+
# Prefer partitioned table
|
| 553 |
+
try:
|
| 554 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 555 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 556 |
+
except Exception:
|
| 557 |
+
try:
|
| 558 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 559 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 560 |
+
except Exception:
|
| 561 |
+
return []
|
| 562 |
+
|
| 563 |
+
def delete_item(table: str, item_id: int):
|
| 564 |
+
try:
|
| 565 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 566 |
+
except Exception as e:
|
| 567 |
+
raise e
|
| 568 |
+
|
| 569 |
+
if st.session_state.get("active_visit"):
|
| 570 |
+
st.subheader("🧾 Items in this visit")
|
| 571 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 572 |
+
if rows:
|
| 573 |
+
df = pd.DataFrame(rows)
|
| 574 |
+
st.dataframe(df, use_container_width=True)
|
| 575 |
+
with st.expander("🗑️ Delete an item (if mis-logged)"):
|
| 576 |
+
ids = [r["id"] for r in rows if "id" in r]
|
| 577 |
+
choice = st.selectbox("Choose item id", ids) if ids else None
|
| 578 |
+
st.markdown('<div class="cc-danger">', unsafe_allow_html=True)
|
| 579 |
+
if st.button("Delete selected", disabled=not bool(ids)):
|
| 580 |
+
if choice is None:
|
| 581 |
+
st.warning("Pick an id.")
|
| 582 |
+
else:
|
| 583 |
+
# try both tables safely
|
| 584 |
+
try:
|
| 585 |
+
delete_item("visit_items_p", int(choice))
|
| 586 |
+
except Exception:
|
| 587 |
+
delete_item("visit_items", int(choice))
|
| 588 |
+
st.success("Deleted.")
|
| 589 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 590 |
+
else:
|
| 591 |
+
st.caption("No items logged for this visit yet.")
|
| 592 |
+
|
| 593 |
+
# ------------------------ Analytics (light) ------------------------
|
| 594 |
+
st.subheader("📈 Today")
|
| 595 |
+
try:
|
| 596 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 597 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 598 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 599 |
+
visits = int(today["visits"]) if today and "visits" in today else 0
|
| 600 |
+
items = int(today["items"]) if today and "items" in today else 0
|
| 601 |
+
st.markdown(f"**Visits:** {visits} · **Items:** {items}")
|
| 602 |
+
except Exception:
|
| 603 |
+
st.caption("Analytics view unavailable yet.")
|
| 604 |
+
|
| 605 |
+
# ------------------------ Gentle reminder ------------------------
|
| 606 |
+
st.markdown(
|
| 607 |
+
"""<div class="cc-hint">
|
| 608 |
+
💡 When you’re done, please <b>End Visit</b> and <b>Sign out</b>.<br>
|
| 609 |
+
We’ll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 610 |
+
</div>""",
|
| 611 |
+
unsafe_allow_html=True
|
| 612 |
+
)
|
backups/pre_test_backup_20250923_202627/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.33
|
| 2 |
+
pandas>=2.1
|
| 3 |
+
Pillow>=10.1
|
| 4 |
+
supabase>=2.5
|
| 5 |
+
huggingface_hub>=0.24.6
|
| 6 |
+
requests>=2.31
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
pytz>=2023.3
|
| 9 |
+
|
backups/pre_test_backup_20250923_202627/secrets.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase connection
|
| 2 |
+
SUPABASE_URL = "https://oudjcqhyldpncjbxohsg.supabase.co"
|
| 3 |
+
SUPABASE_KEY ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91ZGpjcWh5bGRwbmNqYnhvaHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTUzMDgyNDQsImV4cCI6MjA3MDg4NDI0NH0.SYwTtCe3r7WOZ25CRggL-lBkvGPjKnQhY_dT4Kjv9Dg"
|
| 4 |
+
|
| 5 |
+
# App settings
|
| 6 |
+
APP_TZ = "America/Toronto"
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_TOKEN = "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"
|
| 10 |
+
|
| 11 |
+
# Optional VLM model provider
|
| 12 |
+
PROVIDER = "nebius"
|
| 13 |
+
GEMMA_MODEL = "google/gemma-3-27b-it"
|
| 14 |
+
NEBIUS_API_KEY = "eyJhbGciOiJIUzI1NiIsImtpZCI6IlV6SXJWd1h0dnprLVRvdzlLZWstc0M1akptWXBvX1VaVkxUZlpnMDRlOFUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMjk4MzMxNjQ1OTQzNjA5ODYxMyIsInNjb3BlIjoib3BlbmlkIG9mZmxpbmVfYWNjZXNzIiwiaXNzIjoiYXBpX2tleV9pc3N1ZXIiLCJhdWQiOlsiaHR0cHM6Ly9uZWJpdXMtaW5mZXJlbmNlLmV1LmF1dGgwLmNvbS9hcGkvdjIvIl0sImV4cCI6MTkxNTEzMTE3MiwidXVpZCI6IjJmOTUxZmM1LWEyNTgtNDg5OC1iNWI4LTkwZTljY2RkZjU2NSIsIm5hbWUiOiJjYXJlIGNvdW50IGFwcCIsImV4cGlyZXNfYXQiOiIyMDMwLTA5LTA4VDIwOjUyOjUyKzAwMDAifQ.awAHOieD93pCnYmFMgRQ64-i6yZdO0XvXczD4eS6xEI"
|
| 15 |
+
NEBIUS_BASE_URL = "https://api.nebius.ai/v1"
|
backups/pre_test_backup_20250923_202627/streamlit_app.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Modern UI/UX with industry-standard design patterns
|
| 3 |
+
# Enhanced version with improved user experience
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os, io, time, base64, re, uuid, json
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import pytz
|
| 13 |
+
import requests
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import streamlit as st
|
| 16 |
+
from PIL import Image, ImageOps, ImageEnhance
|
| 17 |
+
from supabase import create_client, Client
|
| 18 |
+
|
| 19 |
+
# Import our modern UI components
|
| 20 |
+
from ui_improvements import ModernUIComponents, apply_modern_ui, create_modern_layout
|
| 21 |
+
|
| 22 |
+
# ------------------------ Enhanced Logging ------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
handlers=[
|
| 27 |
+
logging.FileHandler('care_count.log'),
|
| 28 |
+
logging.StreamHandler()
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ------------------------ App config ------------------------
|
| 34 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 35 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
| 36 |
+
|
| 37 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 38 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 39 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 40 |
+
|
| 41 |
+
def local_now() -> datetime:
|
| 42 |
+
return datetime.now(pytz.timezone(TZ))
|
| 43 |
+
|
| 44 |
+
# Enhanced page config with modern settings
|
| 45 |
+
st.set_page_config(
|
| 46 |
+
page_title="Care Count - Volunteer Management",
|
| 47 |
+
page_icon="💜",
|
| 48 |
+
layout="wide",
|
| 49 |
+
initial_sidebar_state="expanded",
|
| 50 |
+
menu_items={
|
| 51 |
+
'Get Help': 'https://github.com/your-repo/care-count',
|
| 52 |
+
'Report a bug': "https://github.com/your-repo/care-count/issues",
|
| 53 |
+
'About': "# Care Count\nVolunteer management system for community impact tracking"
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Apply modern UI styling
|
| 58 |
+
apply_modern_ui()
|
| 59 |
+
|
| 60 |
+
# ------------------------ Enhanced Secrets Management ------------------------
|
| 61 |
+
try:
|
| 62 |
+
from dotenv import load_dotenv
|
| 63 |
+
load_dotenv()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
_SECRETS = getattr(st, "secrets", {})
|
| 69 |
+
except Exception:
|
| 70 |
+
_SECRETS = {}
|
| 71 |
+
|
| 72 |
+
def _is_useful(val: Optional[str]) -> bool:
|
| 73 |
+
return val is not None and str(val).strip() != ""
|
| 74 |
+
|
| 75 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 76 |
+
"""Enhanced secret management with logging"""
|
| 77 |
+
v = os.getenv(name)
|
| 78 |
+
if _is_useful(v):
|
| 79 |
+
logger.info(f"Secret {name} loaded from environment")
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
if hasattr(_SECRETS, "get"):
|
| 84 |
+
v = _SECRETS.get(name, default)
|
| 85 |
+
if _is_useful(v):
|
| 86 |
+
logger.info(f"Secret {name} loaded from Streamlit secrets")
|
| 87 |
+
return v
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Failed to load secret {name} from Streamlit secrets: {e}")
|
| 90 |
+
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def require_secret(name: str) -> str:
|
| 94 |
+
"""Enhanced secret requirement with better error handling"""
|
| 95 |
+
v = get_secret(name, None)
|
| 96 |
+
if not _is_useful(v):
|
| 97 |
+
st.error(
|
| 98 |
+
f"🔐 **Configuration Error**\n\n"
|
| 99 |
+
f"Missing required configuration: `{name}`\n\n"
|
| 100 |
+
f"Please ensure this is set in your environment variables or `.streamlit/secrets.toml` file.\n\n"
|
| 101 |
+
f"For help, check the [documentation](https://github.com/your-repo/care-count#configuration)."
|
| 102 |
+
)
|
| 103 |
+
st.stop()
|
| 104 |
+
return str(v)
|
| 105 |
+
|
| 106 |
+
# Required Supabase credentials
|
| 107 |
+
SUPABASE_URL = require_secret("SUPABASE_URL")
|
| 108 |
+
SUPABASE_KEY = require_secret("SUPABASE_KEY")
|
| 109 |
+
|
| 110 |
+
# Initialize Supabase client with error handling
|
| 111 |
+
try:
|
| 112 |
+
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 113 |
+
logger.info("Supabase client initialized successfully")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Failed to connect to Supabase: {e}")
|
| 116 |
+
logger.error(f"Supabase connection failed: {e}")
|
| 117 |
+
st.stop()
|
| 118 |
+
|
| 119 |
+
# Optional provider/config
|
| 120 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 121 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 122 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 123 |
+
NEBIUS_BASE_URL = get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 124 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 125 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 126 |
+
|
| 127 |
+
# ------------------------ Enhanced Event Logging ------------------------
|
| 128 |
+
def log_event(action: str, actor: Optional[str], details: dict, level: str = "info"):
|
| 129 |
+
"""Enhanced event logging with multiple levels"""
|
| 130 |
+
log_data = {
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 132 |
+
"action": action,
|
| 133 |
+
"actor_email": actor,
|
| 134 |
+
"details": details,
|
| 135 |
+
"level": level
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Log to file
|
| 139 |
+
if level == "error":
|
| 140 |
+
logger.error(f"Event: {action} by {actor} - {details}")
|
| 141 |
+
elif level == "warning":
|
| 142 |
+
logger.warning(f"Event: {action} by {actor} - {details}")
|
| 143 |
+
else:
|
| 144 |
+
logger.info(f"Event: {action} by {actor} - {details}")
|
| 145 |
+
|
| 146 |
+
# Log to database
|
| 147 |
+
try:
|
| 148 |
+
sb.table("events").insert(log_data).execute()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Failed to log event to database: {e}")
|
| 151 |
+
|
| 152 |
+
# ------------------------ Modern Authentication UI ------------------------
|
| 153 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 154 |
+
"""Enhanced authentication with modern UI"""
|
| 155 |
+
if "auth_email" not in st.session_state:
|
| 156 |
+
st.session_state["auth_email"] = None
|
| 157 |
+
if "user_email" in st.session_state:
|
| 158 |
+
return True, st.session_state["user_email"]
|
| 159 |
+
|
| 160 |
+
# Modern hero section for login
|
| 161 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 162 |
+
"Care Count",
|
| 163 |
+
"Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time."
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
# Modern login form
|
| 167 |
+
with st.container():
|
| 168 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 169 |
+
st.subheader("🔐 Sign In")
|
| 170 |
+
st.markdown("Enter your email to receive a secure login code.")
|
| 171 |
+
|
| 172 |
+
with st.form("otp_request", clear_on_submit=False):
|
| 173 |
+
email = st.text_input(
|
| 174 |
+
"Email Address",
|
| 175 |
+
value=st.session_state.get("auth_email") or "",
|
| 176 |
+
placeholder="your.email@example.com",
|
| 177 |
+
help="We'll send you a secure 6-digit code"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 181 |
+
with col2:
|
| 182 |
+
send = st.form_submit_button("📧 Send Login Code", use_container_width=True)
|
| 183 |
+
|
| 184 |
+
if send:
|
| 185 |
+
if not email or "@" not in email:
|
| 186 |
+
st.error("Please enter a valid email address.")
|
| 187 |
+
else:
|
| 188 |
+
try:
|
| 189 |
+
with st.spinner("Sending login code..."):
|
| 190 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 191 |
+
st.session_state["auth_email"] = email
|
| 192 |
+
st.success("✅ Login code sent! Check your email.")
|
| 193 |
+
log_event("otp_requested", email, {"method": "email"})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
st.error(f"❌ Could not send code: {e}")
|
| 196 |
+
log_event("otp_failed", email, {"error": str(e)}, "error")
|
| 197 |
+
|
| 198 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 199 |
+
|
| 200 |
+
# OTP verification form
|
| 201 |
+
if st.session_state.get("auth_email"):
|
| 202 |
+
with st.container():
|
| 203 |
+
st.markdown('<div class="modern-card">', unsafe_allow_html=True)
|
| 204 |
+
st.subheader("🔢 Verify Code")
|
| 205 |
+
st.markdown(f"Enter the 6-digit code sent to **{st.session_state['auth_email']}**")
|
| 206 |
+
|
| 207 |
+
with st.form("otp_verify", clear_on_submit=True):
|
| 208 |
+
code = st.text_input(
|
| 209 |
+
"Verification Code",
|
| 210 |
+
max_chars=6,
|
| 211 |
+
placeholder="123456",
|
| 212 |
+
help="Enter the 6-digit code from your email"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 216 |
+
with col2:
|
| 217 |
+
ok = st.form_submit_button("✅ Verify & Start Shift", use_container_width=True)
|
| 218 |
+
|
| 219 |
+
if ok:
|
| 220 |
+
if len(code) != 6 or not code.isdigit():
|
| 221 |
+
st.error("Please enter a valid 6-digit code.")
|
| 222 |
+
else:
|
| 223 |
+
try:
|
| 224 |
+
with st.spinner("Verifying code..."):
|
| 225 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 226 |
+
if res and res.user:
|
| 227 |
+
email = st.session_state["auth_email"]
|
| 228 |
+
|
| 229 |
+
# Enhanced volunteer upsert
|
| 230 |
+
volunteer_data = {
|
| 231 |
+
"email": email,
|
| 232 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 233 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 234 |
+
"shift_ended_at": None,
|
| 235 |
+
"login_count": 1 # Track login frequency
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
sb.table("volunteers").upsert(volunteer_data, on_conflict="email").execute()
|
| 239 |
+
|
| 240 |
+
st.session_state["user_email"] = email
|
| 241 |
+
st.session_state["shift_started"] = True
|
| 242 |
+
st.session_state["last_activity_at"] = local_now()
|
| 243 |
+
|
| 244 |
+
log_event("login_success", email, {"method": "otp"})
|
| 245 |
+
st.success("🎉 Welcome back! Your shift has started.")
|
| 246 |
+
st.balloons()
|
| 247 |
+
return True, email
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"❌ Verification failed: {e}")
|
| 250 |
+
log_event("otp_verification_failed", st.session_state["auth_email"], {"error": str(e)}, "error")
|
| 251 |
+
|
| 252 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 253 |
+
|
| 254 |
+
return False, None
|
| 255 |
+
|
| 256 |
+
def end_shift(email: str, reason: str):
|
| 257 |
+
"""Enhanced shift ending with better logging"""
|
| 258 |
+
try:
|
| 259 |
+
end_time = datetime.utcnow().isoformat()
|
| 260 |
+
sb.table("volunteers").update({"shift_ended_at": end_time}).eq("email", email).execute()
|
| 261 |
+
log_event("shift_end", email, {"reason": reason, "end_time": end_time})
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to end shift for {email}: {e}")
|
| 264 |
+
|
| 265 |
+
def guard_cutoff_and_idle(email: str):
|
| 266 |
+
"""Enhanced session management with better UX"""
|
| 267 |
+
now = local_now()
|
| 268 |
+
last = st.session_state.get("last_activity_at")
|
| 269 |
+
|
| 270 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 271 |
+
end_shift(email, "inactivity")
|
| 272 |
+
st.session_state.clear()
|
| 273 |
+
st.warning("⏰ You were logged out due to inactivity. Thank you for volunteering today!")
|
| 274 |
+
st.stop()
|
| 275 |
+
|
| 276 |
+
st.session_state["last_activity_at"] = now
|
| 277 |
+
|
| 278 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 279 |
+
if now >= cutoff:
|
| 280 |
+
end_shift(email, "cutoff_8pm")
|
| 281 |
+
st.session_state.clear()
|
| 282 |
+
st.info("🌅 We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 283 |
+
st.stop()
|
| 284 |
+
|
| 285 |
+
# ------------------------ Main App Flow ------------------------
|
| 286 |
+
def main():
|
| 287 |
+
"""Main application flow with modern UI"""
|
| 288 |
+
|
| 289 |
+
# Authentication
|
| 290 |
+
signed_in, user_email = auth_block()
|
| 291 |
+
if not signed_in:
|
| 292 |
+
st.stop()
|
| 293 |
+
|
| 294 |
+
guard_cutoff_and_idle(user_email)
|
| 295 |
+
|
| 296 |
+
# Modern welcome section
|
| 297 |
+
st.markdown(ModernUIComponents.create_hero_section(
|
| 298 |
+
"Care Count Dashboard",
|
| 299 |
+
"Manage visits, track items, and make a difference in your community.",
|
| 300 |
+
user_email
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
# Enhanced status cards
|
| 304 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 305 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 306 |
+
lifetime_hours = vrow.get("total_hours", 0)
|
| 307 |
+
|
| 308 |
+
mins_active = 0
|
| 309 |
+
try:
|
| 310 |
+
if shift_started_at:
|
| 311 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 312 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
status_data = {
|
| 317 |
+
"shift_active": f"{mins_active} min",
|
| 318 |
+
"items_today": items_today(user_email),
|
| 319 |
+
"lifetime_hours": f"{round(float(lifetime_hours), 1)} hrs" if isinstance(lifetime_hours, (int, float)) else "0.0 hrs"
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
st.markdown(ModernUIComponents.create_status_cards(status_data), unsafe_allow_html=True)
|
| 323 |
+
|
| 324 |
+
# Modern sign-out button
|
| 325 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 326 |
+
with col2:
|
| 327 |
+
if st.button("🔒 Sign Out", use_container_width=True, type="secondary"):
|
| 328 |
+
end_shift(user_email, "manual")
|
| 329 |
+
st.session_state.clear()
|
| 330 |
+
st.success("✅ Signed out successfully. See you next time!")
|
| 331 |
+
st.rerun()
|
| 332 |
+
|
| 333 |
+
# Enhanced visit management section
|
| 334 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 335 |
+
"🪪 Visit Management",
|
| 336 |
+
"Start and manage student visits with unique tracking codes"
|
| 337 |
+
), unsafe_allow_html=True)
|
| 338 |
+
|
| 339 |
+
active_visit = st.session_state.get("active_visit")
|
| 340 |
+
|
| 341 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 342 |
+
|
| 343 |
+
with col1:
|
| 344 |
+
if not active_visit and st.button("🚀 Start New Visit", use_container_width=True, type="primary"):
|
| 345 |
+
try:
|
| 346 |
+
with st.spinner("Creating visit..."):
|
| 347 |
+
payload = {
|
| 348 |
+
"visit_code": fallback_visit_code(),
|
| 349 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 350 |
+
"ended_at": None,
|
| 351 |
+
"created_by": user_email
|
| 352 |
+
}
|
| 353 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 354 |
+
if not v.get("visit_code"):
|
| 355 |
+
v["visit_code"] = payload["visit_code"]
|
| 356 |
+
st.session_state["active_visit"] = v
|
| 357 |
+
st.success(f"✅ Visit #{v['id']} started")
|
| 358 |
+
st.info(f"**Visit Code:** `{v['visit_code']}`")
|
| 359 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 360 |
+
st.rerun()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
st.error(f"❌ Could not start visit: {e}")
|
| 363 |
+
log_event("visit_start_failed", user_email, {"error": str(e)}, "error")
|
| 364 |
+
|
| 365 |
+
with col2:
|
| 366 |
+
if active_visit and st.button("🏁 End Visit", use_container_width=True, type="secondary"):
|
| 367 |
+
try:
|
| 368 |
+
with st.spinner("Ending visit..."):
|
| 369 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 370 |
+
.eq("id", active_visit["id"]).execute()
|
| 371 |
+
st.success("✅ Visit completed successfully")
|
| 372 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 373 |
+
st.session_state.pop("active_visit", None)
|
| 374 |
+
st.rerun()
|
| 375 |
+
except Exception as e:
|
| 376 |
+
st.error(f"❌ Could not end visit: {e}")
|
| 377 |
+
log_event("visit_end_failed", user_email, {"error": str(e)}, "error")
|
| 378 |
+
|
| 379 |
+
with col3:
|
| 380 |
+
if st.session_state.get("active_visit"):
|
| 381 |
+
v = st.session_state["active_visit"]
|
| 382 |
+
st.info(f"**Active Visit:** #{v['id']}\n**Code:** {v.get('visit_code','')}")
|
| 383 |
+
else:
|
| 384 |
+
st.info("No active visit")
|
| 385 |
+
|
| 386 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Enhanced item identification section
|
| 389 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 390 |
+
"📸 Item Identification",
|
| 391 |
+
"Use AI-powered image recognition to identify items quickly and accurately"
|
| 392 |
+
), unsafe_allow_html=True)
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
|
| 396 |
+
with col1:
|
| 397 |
+
st.subheader("📷 Camera Capture")
|
| 398 |
+
cam = st.camera_input("Take a photo of the item", help="Position the item clearly in the frame")
|
| 399 |
+
|
| 400 |
+
with col2:
|
| 401 |
+
st.subheader("📁 File Upload")
|
| 402 |
+
up = st.file_uploader(
|
| 403 |
+
"Upload an image",
|
| 404 |
+
type=["png","jpg","jpeg"],
|
| 405 |
+
help="Supported formats: PNG, JPG, JPEG"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
img_file = cam or up
|
| 409 |
+
if img_file:
|
| 410 |
+
try:
|
| 411 |
+
img = Image.open(img_file).convert("RGB")
|
| 412 |
+
st.image(img, use_container_width=True, caption="Captured Image")
|
| 413 |
+
|
| 414 |
+
if st.button("🔍 Identify Item with AI", use_container_width=True, type="primary"):
|
| 415 |
+
with st.spinner("Analyzing image with AI..."):
|
| 416 |
+
t0 = time.time()
|
| 417 |
+
try:
|
| 418 |
+
pre = preprocess_for_label(img)
|
| 419 |
+
raw = gemma_item_name(_to_png_bytes(pre))
|
| 420 |
+
processing_time = time.time() - t0
|
| 421 |
+
|
| 422 |
+
norm = normalize_item_name(raw)
|
| 423 |
+
|
| 424 |
+
if raw:
|
| 425 |
+
st.success(f"🤖 **AI Detection:** {raw}")
|
| 426 |
+
st.info(f"✨ **Normalized:** {norm or '(unknown)'} · ⏱️ {processing_time:.2f}s")
|
| 427 |
+
|
| 428 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 429 |
+
st.session_state["last_activity_at"] = local_now()
|
| 430 |
+
|
| 431 |
+
log_event("item_identified", user_email, {
|
| 432 |
+
"raw_name": raw,
|
| 433 |
+
"normalized_name": norm,
|
| 434 |
+
"processing_time": processing_time
|
| 435 |
+
})
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
st.error(f"❌ AI identification failed: {e}")
|
| 439 |
+
log_event("ai_identification_failed", user_email, {"error": str(e)}, "error")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
st.error(f"❌ Failed to process image: {e}")
|
| 442 |
+
log_event("image_processing_failed", user_email, {"error": str(e)}, "error")
|
| 443 |
+
|
| 444 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
# Enhanced item logging section
|
| 447 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 448 |
+
"📬 Item Logging",
|
| 449 |
+
"Log items to the current visit with detailed information"
|
| 450 |
+
), unsafe_allow_html=True)
|
| 451 |
+
|
| 452 |
+
col1, col2 = st.columns([2, 1])
|
| 453 |
+
|
| 454 |
+
with col1:
|
| 455 |
+
item_name = st.text_input(
|
| 456 |
+
"Item Name",
|
| 457 |
+
value=st.session_state.get("scanned_item_name",""),
|
| 458 |
+
placeholder="Enter item name or use AI detection above",
|
| 459 |
+
help="Required field - item name for tracking"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
with col2:
|
| 463 |
+
quantity = st.number_input(
|
| 464 |
+
"Quantity",
|
| 465 |
+
min_value=1,
|
| 466 |
+
max_value=9999,
|
| 467 |
+
step=1,
|
| 468 |
+
value=1,
|
| 469 |
+
help="Number of items"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
col3, col4 = st.columns(2)
|
| 473 |
+
|
| 474 |
+
with col3:
|
| 475 |
+
category = st.text_input(
|
| 476 |
+
"Category (optional)",
|
| 477 |
+
placeholder="e.g., Food, Hygiene, Clothing",
|
| 478 |
+
help="Item category for better organization"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
with col4:
|
| 482 |
+
unit = st.text_input(
|
| 483 |
+
"Unit (optional)",
|
| 484 |
+
placeholder="e.g., 500 mL, 1 L, 250 g",
|
| 485 |
+
help="Unit of measurement"
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
barcode = st.text_input(
|
| 489 |
+
"Barcode (optional)",
|
| 490 |
+
placeholder="Scan or enter barcode",
|
| 491 |
+
help="Product barcode for inventory tracking"
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 495 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled, use_container_width=True, type="primary"):
|
| 496 |
+
v = st.session_state.get("active_visit")
|
| 497 |
+
if not v:
|
| 498 |
+
st.warning("⚠️ Please start a visit first before logging items.")
|
| 499 |
+
else:
|
| 500 |
+
name_clean = clean_text(item_name, 120)
|
| 501 |
+
if not name_clean:
|
| 502 |
+
st.warning("⚠️ Item name is required.")
|
| 503 |
+
else:
|
| 504 |
+
with st.spinner("Saving item..."):
|
| 505 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 506 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
ok, msg = try_rpc_ingest(
|
| 510 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 511 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 512 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if ok:
|
| 516 |
+
st.success("✅ Item logged successfully!")
|
| 517 |
+
log_event("item_logged", user_email, {
|
| 518 |
+
"visit_id": v["id"],
|
| 519 |
+
"item_name": name_clean,
|
| 520 |
+
"quantity": quantity
|
| 521 |
+
})
|
| 522 |
+
else:
|
| 523 |
+
st.warning(f"⚠️ {msg}. Trying fallback method...")
|
| 524 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 525 |
+
clean_text(category,80), clean_text(unit,40),
|
| 526 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 527 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 528 |
+
log_event("item_logged_fallback", user_email, {
|
| 529 |
+
"visit_id": v["id"],
|
| 530 |
+
"item_name": name_clean,
|
| 531 |
+
"quantity": quantity
|
| 532 |
+
})
|
| 533 |
+
except Exception as e:
|
| 534 |
+
try:
|
| 535 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 536 |
+
clean_text(category,80), clean_text(unit,40),
|
| 537 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 538 |
+
st.success("✅ Item logged successfully (fallback method)!")
|
| 539 |
+
log_event("item_logged_fallback", user_email, {
|
| 540 |
+
"visit_id": v["id"],
|
| 541 |
+
"item_name": name_clean,
|
| 542 |
+
"quantity": quantity
|
| 543 |
+
})
|
| 544 |
+
except Exception as e2:
|
| 545 |
+
st.error(f"❌ Failed to log item: {e2}")
|
| 546 |
+
log_event("item_log_failed", user_email, {"error": str(e2)}, "error")
|
| 547 |
+
|
| 548 |
+
st.session_state["last_activity_at"] = local_now()
|
| 549 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 550 |
+
st.rerun()
|
| 551 |
+
|
| 552 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 553 |
+
|
| 554 |
+
# Enhanced visit items view
|
| 555 |
+
if st.session_state.get("active_visit"):
|
| 556 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 557 |
+
"🧾 Current Visit Items",
|
| 558 |
+
"Review and manage items in the current visit"
|
| 559 |
+
), unsafe_allow_html=True)
|
| 560 |
+
|
| 561 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 562 |
+
if rows:
|
| 563 |
+
df = pd.DataFrame(rows)
|
| 564 |
+
|
| 565 |
+
# Enhanced dataframe display
|
| 566 |
+
st.dataframe(
|
| 567 |
+
df,
|
| 568 |
+
use_container_width=True,
|
| 569 |
+
hide_index=True,
|
| 570 |
+
column_config={
|
| 571 |
+
"timestamp": st.column_config.DatetimeColumn("Time", format="MM/DD/YY HH:mm"),
|
| 572 |
+
"item_name": st.column_config.TextColumn("Item Name", width="medium"),
|
| 573 |
+
"qty": st.column_config.NumberColumn("Quantity", width="small"),
|
| 574 |
+
"category": st.column_config.TextColumn("Category", width="small"),
|
| 575 |
+
"unit": st.column_config.TextColumn("Unit", width="small")
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Enhanced delete functionality
|
| 580 |
+
with st.expander("🗑️ Delete Item (if mis-logged)"):
|
| 581 |
+
if rows:
|
| 582 |
+
item_options = {f"{r['item_name']} (Qty: {r['qty']})": r["id"] for r in rows if "id" in r}
|
| 583 |
+
selected_item = st.selectbox("Select item to delete", list(item_options.keys()))
|
| 584 |
+
|
| 585 |
+
if st.button("🗑️ Delete Selected Item", type="secondary"):
|
| 586 |
+
item_id = item_options[selected_item]
|
| 587 |
+
try:
|
| 588 |
+
# Try both tables safely
|
| 589 |
+
try:
|
| 590 |
+
delete_item("visit_items_p", int(item_id))
|
| 591 |
+
except Exception:
|
| 592 |
+
delete_item("visit_items", int(item_id))
|
| 593 |
+
|
| 594 |
+
st.success("✅ Item deleted successfully")
|
| 595 |
+
log_event("item_deleted", user_email, {"item_id": item_id})
|
| 596 |
+
st.rerun()
|
| 597 |
+
except Exception as e:
|
| 598 |
+
st.error(f"❌ Failed to delete item: {e}")
|
| 599 |
+
log_event("item_delete_failed", user_email, {"error": str(e)}, "error")
|
| 600 |
+
else:
|
| 601 |
+
st.info("No items to delete")
|
| 602 |
+
else:
|
| 603 |
+
st.info("📝 No items logged for this visit yet.")
|
| 604 |
+
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# Enhanced analytics section
|
| 608 |
+
st.markdown(ModernUIComponents.create_modern_form_section(
|
| 609 |
+
"📈 Today's Analytics",
|
| 610 |
+
"Real-time insights into today's volunteer activity"
|
| 611 |
+
), unsafe_allow_html=True)
|
| 612 |
+
|
| 613 |
+
try:
|
| 614 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 615 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 616 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 617 |
+
|
| 618 |
+
if today:
|
| 619 |
+
visits = int(today.get("visits", 0))
|
| 620 |
+
items = int(today.get("items", 0))
|
| 621 |
+
|
| 622 |
+
col1, col2 = st.columns(2)
|
| 623 |
+
with col1:
|
| 624 |
+
st.metric("Total Visits Today", visits, delta=None)
|
| 625 |
+
with col2:
|
| 626 |
+
st.metric("Total Items Processed", items, delta=None)
|
| 627 |
+
|
| 628 |
+
# Progress indicator
|
| 629 |
+
if visits > 0:
|
| 630 |
+
st.markdown(ModernUIComponents.create_progress_indicator(
|
| 631 |
+
items, visits * 10, "Items per Visit Target"
|
| 632 |
+
), unsafe_allow_html=True)
|
| 633 |
+
else:
|
| 634 |
+
st.info("📊 Analytics data will appear here as activity increases.")
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
st.info("📊 Analytics view will be available once the database view is set up.")
|
| 638 |
+
logger.warning(f"Analytics view not available: {e}")
|
| 639 |
+
|
| 640 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 641 |
+
|
| 642 |
+
# Enhanced footer with helpful information
|
| 643 |
+
st.markdown("""
|
| 644 |
+
<div class="modern-card" style="margin-top: var(--space-8); text-align: center;">
|
| 645 |
+
<h4>💡 Quick Tips</h4>
|
| 646 |
+
<p style="color: var(--gray-400); margin: 0;">
|
| 647 |
+
When you're done, please <strong>End Visit</strong> and <strong>Sign out</strong>.<br>
|
| 648 |
+
We'll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 649 |
+
</p>
|
| 650 |
+
</div>
|
| 651 |
+
""", unsafe_allow_html=True)
|
| 652 |
+
|
| 653 |
+
# ------------------------ Helper Functions (Enhanced) ------------------------
|
| 654 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 655 |
+
"""Enhanced volunteer data fetching with error handling"""
|
| 656 |
+
try:
|
| 657 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.warning(f"Failed to fetch volunteer data for {email}: {e}")
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
def items_today(email: str) -> int:
|
| 663 |
+
"""Enhanced item counting with better error handling"""
|
| 664 |
+
try:
|
| 665 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 666 |
+
# Try partitioned table first
|
| 667 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 668 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 669 |
+
.eq("volunteer", email).execute().data
|
| 670 |
+
return len(data or [])
|
| 671 |
+
except Exception:
|
| 672 |
+
try:
|
| 673 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 674 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 675 |
+
.eq("volunteer", email).execute().data
|
| 676 |
+
return len(data or [])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.warning(f"Failed to count items for {email}: {e}")
|
| 679 |
+
return 0
|
| 680 |
+
|
| 681 |
+
def fallback_visit_code() -> str:
|
| 682 |
+
"""Enhanced visit code generation with better uniqueness"""
|
| 683 |
+
try:
|
| 684 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 685 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 686 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 687 |
+
seq = len(todays) + 1
|
| 688 |
+
except Exception:
|
| 689 |
+
seq = int(time.time()) % 1000
|
| 690 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 691 |
+
|
| 692 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 693 |
+
"""Convert image to PNG bytes"""
|
| 694 |
+
b = io.BytesIO()
|
| 695 |
+
img.save(b, format="PNG")
|
| 696 |
+
return b.getvalue()
|
| 697 |
+
|
| 698 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 699 |
+
"""Enhanced image preprocessing for better AI recognition"""
|
| 700 |
+
img = img.convert("RGB")
|
| 701 |
+
w, h = img.size
|
| 702 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 703 |
+
if scale < 1.0:
|
| 704 |
+
img = img.resize((int(w * scale), int(h * scale)))
|
| 705 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 706 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 707 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 708 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 709 |
+
return img
|
| 710 |
+
|
| 711 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 712 |
+
"""Enhanced AI item identification with better error handling"""
|
| 713 |
+
try:
|
| 714 |
+
if PROVIDER == "nebius":
|
| 715 |
+
if not NEBIUS_API_KEY:
|
| 716 |
+
raise RuntimeError("NEBIUS_API_KEY missing")
|
| 717 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 718 |
+
elif PROVIDER == "featherless":
|
| 719 |
+
if not FEATH_API_KEY:
|
| 720 |
+
raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 721 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 722 |
+
else:
|
| 723 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"AI identification failed: {e}")
|
| 726 |
+
raise
|
| 727 |
+
|
| 728 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 729 |
+
"""Enhanced API communication with better error handling"""
|
| 730 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 731 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 732 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 733 |
+
payload = {
|
| 734 |
+
"model": model_id,
|
| 735 |
+
"temperature": 0,
|
| 736 |
+
"messages": [
|
| 737 |
+
{"role": "system", "content": "You label item being held in the image for a food bank. Return ONLY the item name."},
|
| 738 |
+
{"role": "user", "content": [
|
| 739 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 740 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 741 |
+
]}
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 747 |
+
if r.status_code != 200:
|
| 748 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 749 |
+
data = r.json()
|
| 750 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 751 |
+
except requests.exceptions.Timeout:
|
| 752 |
+
raise RuntimeError("AI service timeout - please try again")
|
| 753 |
+
except requests.exceptions.RequestException as e:
|
| 754 |
+
raise RuntimeError(f"AI service error: {e}")
|
| 755 |
+
|
| 756 |
+
def normalize_item_name(s: str) -> str:
|
| 757 |
+
"""Enhanced item name normalization"""
|
| 758 |
+
s = (s or "").strip()
|
| 759 |
+
if not s:
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
# Enhanced brand and type recognition
|
| 763 |
+
BRANDS = {
|
| 764 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 765 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 766 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 767 |
+
}
|
| 768 |
+
GENERIC_TYPES = {
|
| 769 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 770 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 771 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 772 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 776 |
+
for b in BRANDS:
|
| 777 |
+
low = low.replace(b, "")
|
| 778 |
+
|
| 779 |
+
chosen = None
|
| 780 |
+
for t in GENERIC_TYPES:
|
| 781 |
+
if t in low:
|
| 782 |
+
chosen = t
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
cleaned = " ".join(low.split())
|
| 786 |
+
return (chosen or cleaned.title())[:120]
|
| 787 |
+
|
| 788 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 789 |
+
"""Enhanced text cleaning"""
|
| 790 |
+
if not v:
|
| 791 |
+
return None
|
| 792 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 793 |
+
return v[:maxlen] if v else None
|
| 794 |
+
|
| 795 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 796 |
+
"""Enhanced ID generation for data integrity"""
|
| 797 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 798 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 799 |
+
|
| 800 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 801 |
+
category: Optional[str], unit: Optional[str],
|
| 802 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 803 |
+
"""Enhanced RPC ingestion with better error handling"""
|
| 804 |
+
try:
|
| 805 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 806 |
+
"p_email": email,
|
| 807 |
+
"p_visit_id": v_id,
|
| 808 |
+
"p_item_name": name,
|
| 809 |
+
"p_qty": qty,
|
| 810 |
+
"p_category": category,
|
| 811 |
+
"p_unit": unit,
|
| 812 |
+
"p_barcode": barcode,
|
| 813 |
+
"p_ts": ts_iso,
|
| 814 |
+
"p_ingest_id": ingest_id
|
| 815 |
+
}).execute()
|
| 816 |
+
|
| 817 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 818 |
+
if rows:
|
| 819 |
+
r0 = rows[0]
|
| 820 |
+
ok = bool(r0.get("ok", False))
|
| 821 |
+
msg = str(r0.get("msg", ""))
|
| 822 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 823 |
+
return True, "ok"
|
| 824 |
+
except Exception as e:
|
| 825 |
+
logger.warning(f"RPC ingest failed, using fallback: {e}")
|
| 826 |
+
raise e
|
| 827 |
+
|
| 828 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 829 |
+
category: Optional[str], unit: Optional[str],
|
| 830 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 831 |
+
"""Enhanced fallback insertion with better error handling"""
|
| 832 |
+
payload = {
|
| 833 |
+
"visit_id": v_id,
|
| 834 |
+
"timestamp": ts_iso,
|
| 835 |
+
"volunteer": email,
|
| 836 |
+
"item_name": name,
|
| 837 |
+
"category": category,
|
| 838 |
+
"unit": unit,
|
| 839 |
+
"qty": qty,
|
| 840 |
+
"barcode": barcode,
|
| 841 |
+
"weather_type": None,
|
| 842 |
+
"temp_c": None,
|
| 843 |
+
"ingest_id": ingest_id
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 848 |
+
except Exception:
|
| 849 |
+
# Legacy table fallback
|
| 850 |
+
payload.pop("ingest_id", None)
|
| 851 |
+
sb.table("visit_items").insert(payload).execute()
|
| 852 |
+
|
| 853 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 854 |
+
"""Enhanced item loading with better error handling"""
|
| 855 |
+
try:
|
| 856 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 857 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 858 |
+
except Exception:
|
| 859 |
+
try:
|
| 860 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 861 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 862 |
+
except Exception as e:
|
| 863 |
+
logger.warning(f"Failed to load items for visit {visit_id}: {e}")
|
| 864 |
+
return []
|
| 865 |
+
|
| 866 |
+
def delete_item(table: str, item_id: int):
|
| 867 |
+
"""Enhanced item deletion with better error handling"""
|
| 868 |
+
try:
|
| 869 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.error(f"Failed to delete item {item_id} from {table}: {e}")
|
| 872 |
+
raise e
|
| 873 |
+
|
| 874 |
+
# ------------------------ App Configuration Display ------------------------
|
| 875 |
+
st.sidebar.markdown("### ⚙️ Configuration")
|
| 876 |
+
st.sidebar.info(f"""
|
| 877 |
+
**Provider:** `{PROVIDER}`
|
| 878 |
+
**Model:** `{GEMMA_MODEL}`
|
| 879 |
+
**Timezone:** `{TZ}`
|
| 880 |
+
**Cutoff:** {CUTOFF_HOUR}:00 PM
|
| 881 |
+
**Inactivity:** {INACTIVITY_MIN} min
|
| 882 |
+
""")
|
| 883 |
+
|
| 884 |
+
# ------------------------ Main App Execution ------------------------
|
| 885 |
+
if __name__ == "__main__":
|
| 886 |
+
try:
|
| 887 |
+
main()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
logger.error(f"Application error: {e}")
|
| 890 |
+
st.error(f"❌ Application error: {e}")
|
| 891 |
+
st.info("Please refresh the page or contact support if the issue persists.")
|