Mr-TD commited on
Commit
aefe381
ยท
1 Parent(s): 254e5b7

feat: Add operator dashboard, alerts, analytics, and simulator pages

Browse files

- Implemented new HTML templates for operator dashboard, alerts, analytics, and simulator functionalities.
- Created a structured layout for displaying alerts and analytics data.
- Added JavaScript files for real-time updates and notifications.
- Established a testing framework with comprehensive tests for API endpoints, authentication, crowd and queue services, and simulator logic.
- Included fixtures for testing with authenticated clients for both attendees and operators.

This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. .env.example +26 -0
  2. .gitignore +32 -0
  3. Dockerfile +26 -0
  4. app.py +131 -0
  5. blueprints/__init__.py +1 -0
  6. blueprints/api.py +359 -0
  7. blueprints/attendee.py +113 -0
  8. blueprints/auth.py +139 -0
  9. blueprints/operator.py +105 -0
  10. blueprints/sse.py +67 -0
  11. config.py +71 -0
  12. implementation_plan.md +535 -0
  13. models/__init__.py +1 -0
  14. models/event.py +135 -0
  15. models/queue.py +132 -0
  16. models/venue.py +138 -0
  17. requirements.txt +12 -0
  18. services/__init__.py +1 -0
  19. services/crowd_service.py +129 -0
  20. services/firebase_service.py +220 -0
  21. services/gemini_service.py +130 -0
  22. services/maps_service.py +217 -0
  23. services/notification_service.py +128 -0
  24. services/queue_service.py +185 -0
  25. services/simulator.py +364 -0
  26. services/translation_service.py +97 -0
  27. static/css/base.css +1276 -0
  28. static/js/accessibility.js +85 -0
  29. static/js/app.js +116 -0
  30. static/js/chatbot.js +133 -0
  31. static/js/dashboard.js +90 -0
  32. static/js/heatmap.js +232 -0
  33. static/js/notifications.js +59 -0
  34. static/js/queue.js +110 -0
  35. static/js/simulator-ui.js +79 -0
  36. static/js/sse-client.js +84 -0
  37. static/js/wayfinding.js +267 -0
  38. templates/attendee/heatmap.html +102 -0
  39. templates/attendee/home.html +163 -0
  40. templates/attendee/navigate.html +82 -0
  41. templates/attendee/profile.html +77 -0
  42. templates/attendee/queues.html +113 -0
  43. templates/auth/login.html +59 -0
  44. templates/auth/register.html +64 -0
  45. templates/base.html +49 -0
  46. templates/components/chatbot.html +28 -0
  47. templates/components/nav.html +66 -0
  48. templates/errors/404.html +12 -0
  49. templates/errors/500.html +12 -0
  50. templates/operator/alerts.html +40 -0
.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Flask
2
+ FLASK_SECRET_KEY=your-secret-key-here
3
+ FLASK_ENV=development
4
+ FLASK_DEBUG=1
5
+
6
+ # Firebase
7
+ FIREBASE_CREDENTIALS_PATH=path/to/serviceAccountKey.json
8
+ FIREBASE_API_KEY=your-firebase-api-key
9
+ FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
10
+ FIREBASE_PROJECT_ID=your-project-id
11
+ FIREBASE_STORAGE_BUCKET=your-project.appspot.com
12
+ FIREBASE_MESSAGING_SENDER_ID=your-sender-id
13
+ FIREBASE_APP_ID=your-app-id
14
+
15
+ # Google Gemini
16
+ GEMINI_API_KEY=your-gemini-api-key
17
+
18
+ # Google Maps
19
+ GOOGLE_MAPS_API_KEY=your-google-maps-api-key
20
+
21
+ # Google Cloud Translation
22
+ GOOGLE_TRANSLATE_API_KEY=your-translate-api-key
23
+
24
+ # App Config
25
+ USE_MOCK_SERVICES=true
26
+ VENUE_CAPACITY=50000
.gitignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment
2
+ .env
3
+ *.env
4
+ serviceAccountKey.json
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ venv/
13
+ .venv/
14
+ env/
15
+ *.egg-info/
16
+ dist/
17
+ build/
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+ *.swo
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Testing
30
+ .pytest_cache/
31
+ htmlcov/
32
+ .coverage
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Create a user to run the app (Hugging Face Spaces requirement)
6
+ RUN useradd -m -u 1000 user
7
+
8
+ # Install required packages
9
+ COPY requirements.txt /app/
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+ RUN pip install --no-cache-dir gunicorn
12
+
13
+ # Copy the rest of the application
14
+ COPY . /app/
15
+
16
+ # Set ownership to the non-root user so the app can create and write to game.db
17
+ RUN chown -R user:user /app
18
+
19
+ # Switch to the non-root user
20
+ USER user
21
+
22
+ # Expose port 7860 which is the default for Hugging Face Spaces
23
+ EXPOSE 7860
24
+
25
+ # Initialize the database and then start gunicorn
26
+ CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && gunicorn -b 0.0.0.0:7860 app:app"]
app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VenueFlow โ€” Flask application factory."""
2
+
3
+ import logging
4
+ from flask import Flask, redirect, url_for, render_template
5
+ from config import get_config
6
+
7
+ # Configure logging
8
+ logging.basicConfig(
9
+ level=logging.INFO,
10
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
11
+ )
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def create_app(config_class=None):
16
+ """Application factory."""
17
+ app = Flask(__name__)
18
+
19
+ # Load configuration
20
+ if config_class is None:
21
+ config_class = get_config()
22
+ app.config.from_object(config_class)
23
+
24
+ # โ”€โ”€โ”€ Security Headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
+ @app.after_request
26
+ def set_security_headers(response):
27
+ response.headers["X-Content-Type-Options"] = "nosniff"
28
+ response.headers["X-Frame-Options"] = "DENY"
29
+ response.headers["X-XSS-Protection"] = "1; mode=block"
30
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
31
+ if not app.debug:
32
+ response.headers["Strict-Transport-Security"] = (
33
+ "max-age=31536000; includeSubDomains"
34
+ )
35
+ return response
36
+
37
+ # โ”€โ”€โ”€ Initialize Services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
38
+ from services.firebase_service import init_firebase
39
+ from services.gemini_service import init_gemini
40
+ from services.translation_service import init_translation
41
+
42
+ init_firebase(app)
43
+ init_gemini(app)
44
+ init_translation(app)
45
+
46
+ # โ”€โ”€โ”€ Initialize Demo Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
47
+ from services.simulator import (
48
+ create_demo_venue,
49
+ create_demo_queue_stations,
50
+ create_demo_event,
51
+ Simulator,
52
+ )
53
+ from services.crowd_service import CrowdService
54
+ from services.queue_service import QueueService
55
+ from services.maps_service import MapsService
56
+ from services.notification_service import NotificationService
57
+
58
+ venue = create_demo_venue()
59
+ event = create_demo_event()
60
+ crowd_service = CrowdService()
61
+ queue_service = QueueService()
62
+ maps_service = MapsService(venue)
63
+ notification_service = NotificationService()
64
+
65
+ # Register queue stations
66
+ queue_stations = create_demo_queue_stations()
67
+ queue_service.register_stations(queue_stations)
68
+
69
+ # Create simulator
70
+ simulator = Simulator(venue, queue_service, crowd_service, notification_service, event)
71
+
72
+ # Wire SSE broadcasting
73
+ from blueprints.sse import broadcast_update
74
+
75
+ def on_sim_update():
76
+ broadcast_update({
77
+ "venue": venue.to_dict(),
78
+ "event": event.to_dict(),
79
+ "sim_status": simulator.get_status(),
80
+ })
81
+
82
+ simulator.set_update_callback(on_sim_update)
83
+
84
+ # Store services in app config for blueprint access
85
+ app.config["VENUE_OBJ"] = venue
86
+ app.config["EVENT_OBJ"] = event
87
+ app.config["CROWD_SERVICE"] = crowd_service
88
+ app.config["QUEUE_SERVICE"] = queue_service
89
+ app.config["MAPS_SERVICE"] = maps_service
90
+ app.config["NOTIFICATION_SERVICE"] = notification_service
91
+ app.config["SIMULATOR_OBJ"] = simulator
92
+
93
+ # Run initial tick to populate data
94
+ simulator.tick()
95
+
96
+ # โ”€โ”€โ”€ Register Blueprints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
97
+ from blueprints.auth import auth_bp
98
+ from blueprints.attendee import attendee_bp
99
+ from blueprints.operator import operator_bp
100
+ from blueprints.api import api_bp
101
+ from blueprints.sse import sse_bp
102
+
103
+ app.register_blueprint(auth_bp)
104
+ app.register_blueprint(attendee_bp)
105
+ app.register_blueprint(operator_bp)
106
+ app.register_blueprint(api_bp)
107
+ app.register_blueprint(sse_bp)
108
+
109
+ # โ”€โ”€โ”€ Root Route โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
110
+ @app.route("/")
111
+ def index():
112
+ return redirect(url_for("auth.login"))
113
+
114
+ # โ”€โ”€โ”€ Error Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
115
+ @app.errorhandler(404)
116
+ def not_found(e):
117
+ return render_template("errors/404.html"), 404
118
+
119
+ @app.errorhandler(500)
120
+ def server_error(e):
121
+ return render_template("errors/500.html"), 500
122
+
123
+ logger.info("๐ŸŸ๏ธ VenueFlow app created successfully")
124
+ return app
125
+
126
+
127
+ # โ”€โ”€โ”€ Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
+
129
+ if __name__ == "__main__":
130
+ app = create_app()
131
+ app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
blueprints/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Blueprints package."""
blueprints/api.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """REST API endpoints for real-time data access."""
2
+
3
+ from flask import Blueprint, jsonify, request, session, current_app
4
+ import bleach
5
+
6
+ api_bp = Blueprint("api", __name__, url_prefix="/api")
7
+
8
+
9
+ def _get_services():
10
+ return {
11
+ "venue": current_app.config.get("VENUE_OBJ"),
12
+ "event": current_app.config.get("EVENT_OBJ"),
13
+ "crowd": current_app.config.get("CROWD_SERVICE"),
14
+ "queue": current_app.config.get("QUEUE_SERVICE"),
15
+ "maps": current_app.config.get("MAPS_SERVICE"),
16
+ "notifications": current_app.config.get("NOTIFICATION_SERVICE"),
17
+ "simulator": current_app.config.get("SIMULATOR_OBJ"),
18
+ }
19
+
20
+
21
+ # โ”€โ”€โ”€ Crowd Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+
23
+
24
+ @api_bp.route("/crowd/summary")
25
+ def crowd_summary():
26
+ """Get venue crowd summary."""
27
+ svc = _get_services()
28
+ if not svc["crowd"] or not svc["venue"]:
29
+ return jsonify({"error": "Service unavailable"}), 503
30
+ return jsonify(svc["crowd"].get_venue_summary(svc["venue"]))
31
+
32
+
33
+ @api_bp.route("/crowd/heatmap")
34
+ def crowd_heatmap():
35
+ """Get heatmap data for visualization."""
36
+ svc = _get_services()
37
+ if not svc["crowd"] or not svc["venue"]:
38
+ return jsonify({"error": "Service unavailable"}), 503
39
+ return jsonify(svc["crowd"].get_crowd_flow_data(svc["venue"]))
40
+
41
+
42
+ @api_bp.route("/crowd/zone/<zone_id>")
43
+ def crowd_zone(zone_id):
44
+ """Get specific zone data."""
45
+ svc = _get_services()
46
+ venue = svc["venue"]
47
+ if not venue:
48
+ return jsonify({"error": "Service unavailable"}), 503
49
+ zone = venue.get_zone(zone_id)
50
+ if not zone:
51
+ return jsonify({"error": "Zone not found"}), 404
52
+ trend = svc["crowd"].get_zone_trend(zone_id) if svc["crowd"] else []
53
+ return jsonify({"zone": zone.to_dict(), "trend": trend})
54
+
55
+
56
+ @api_bp.route("/crowd/recommend")
57
+ def crowd_recommend():
58
+ """Get recommended least crowded zones."""
59
+ svc = _get_services()
60
+ zone_type = request.args.get("type")
61
+ return jsonify(svc["crowd"].get_recommended_zones(svc["venue"], zone_type))
62
+
63
+
64
+ # โ”€โ”€โ”€ Queue Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
+
66
+
67
+ @api_bp.route("/queue/summary")
68
+ def queue_summary():
69
+ """Get queue overview."""
70
+ svc = _get_services()
71
+ if not svc["queue"]:
72
+ return jsonify({"error": "Service unavailable"}), 503
73
+ return jsonify(svc["queue"].get_queue_summary())
74
+
75
+
76
+ @api_bp.route("/queue/stations")
77
+ def queue_stations():
78
+ """Get all queue stations."""
79
+ svc = _get_services()
80
+ category = request.args.get("category")
81
+ if category:
82
+ return jsonify(svc["queue"].get_stations_by_category(category))
83
+ return jsonify(svc["queue"].get_all_stations())
84
+
85
+
86
+ @api_bp.route("/queue/shortest")
87
+ def queue_shortest():
88
+ """Find shortest queue."""
89
+ svc = _get_services()
90
+ category = request.args.get("category")
91
+ result = svc["queue"].get_shortest_queue(category)
92
+ if result:
93
+ return jsonify(result)
94
+ return jsonify({"error": "No open stations found"}), 404
95
+
96
+
97
+ @api_bp.route("/queue/join", methods=["POST"])
98
+ def queue_join():
99
+ """Join a virtual queue."""
100
+ svc = _get_services()
101
+ user_id = session.get("user_id")
102
+ if not user_id:
103
+ return jsonify({"error": "Not authenticated"}), 401
104
+
105
+ data = request.get_json()
106
+ station_id = data.get("station_id", "") if data else ""
107
+ if not station_id:
108
+ return jsonify({"error": "Station ID required"}), 400
109
+
110
+ ticket = svc["queue"].join_virtual_queue(user_id, station_id)
111
+ if ticket:
112
+ return jsonify(ticket)
113
+ return jsonify({"error": "Could not join queue"}), 400
114
+
115
+
116
+ @api_bp.route("/queue/tickets")
117
+ def queue_tickets():
118
+ """Get user's virtual queue tickets."""
119
+ svc = _get_services()
120
+ user_id = session.get("user_id")
121
+ if not user_id:
122
+ return jsonify({"error": "Not authenticated"}), 401
123
+ return jsonify(svc["queue"].get_user_tickets(user_id))
124
+
125
+
126
+ @api_bp.route("/queue/cancel", methods=["POST"])
127
+ def queue_cancel():
128
+ """Cancel a virtual queue ticket."""
129
+ svc = _get_services()
130
+ user_id = session.get("user_id")
131
+ if not user_id:
132
+ return jsonify({"error": "Not authenticated"}), 401
133
+
134
+ data = request.get_json()
135
+ ticket_id = data.get("ticket_id", "") if data else ""
136
+ if svc["queue"].cancel_ticket(ticket_id, user_id):
137
+ return jsonify({"success": True})
138
+ return jsonify({"error": "Could not cancel ticket"}), 400
139
+
140
+
141
+ # โ”€โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
142
+
143
+
144
+ @api_bp.route("/navigate/route")
145
+ def navigate_route():
146
+ """Get route between two zones."""
147
+ svc = _get_services()
148
+ from_zone = request.args.get("from")
149
+ to_zone = request.args.get("to")
150
+ accessible = request.args.get("accessible", "false").lower() == "true"
151
+
152
+ if not from_zone or not to_zone:
153
+ return jsonify({"error": "from and to zone IDs required"}), 400
154
+
155
+ route = svc["maps"].find_route(from_zone, to_zone, accessible)
156
+ if route:
157
+ return jsonify(route)
158
+ return jsonify({"error": "Route not found"}), 404
159
+
160
+
161
+ @api_bp.route("/navigate/nearest")
162
+ def navigate_nearest():
163
+ """Find nearest zone of a type."""
164
+ svc = _get_services()
165
+ from_zone = request.args.get("from")
166
+ target_type = request.args.get("type")
167
+ accessible = request.args.get("accessible", "false").lower() == "true"
168
+
169
+ if not from_zone or not target_type:
170
+ return jsonify({"error": "from zone and type required"}), 400
171
+
172
+ result = svc["maps"].find_nearest(from_zone, target_type, accessible)
173
+ if result:
174
+ return jsonify(result)
175
+ return jsonify({"error": "No matching zone found"}), 404
176
+
177
+
178
+ @api_bp.route("/navigate/pois")
179
+ def navigate_pois():
180
+ """Get all points of interest."""
181
+ svc = _get_services()
182
+ return jsonify(svc["maps"].get_points_of_interest())
183
+
184
+
185
+ # โ”€โ”€โ”€ Chatbot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
186
+
187
+
188
+ @api_bp.route("/chat", methods=["POST"])
189
+ def chat():
190
+ """Send a message to the AI chatbot."""
191
+ from services import gemini_service
192
+
193
+ user_id = session.get("user_id")
194
+ if not user_id:
195
+ return jsonify({"error": "Not authenticated"}), 401
196
+
197
+ data = request.get_json()
198
+ message = bleach.clean(data.get("message", "").strip()) if data else ""
199
+ if not message:
200
+ return jsonify({"error": "Message required"}), 400
201
+ if len(message) > 500:
202
+ return jsonify({"error": "Message too long (max 500 chars)"}), 400
203
+
204
+ # Build venue context for the AI
205
+ svc = _get_services()
206
+ venue_context = None
207
+ if svc["crowd"] and svc["venue"]:
208
+ summary = svc["crowd"].get_venue_summary(svc["venue"])
209
+ queue_summary = svc["queue"].get_queue_summary() if svc["queue"] else {}
210
+ venue_context = {
211
+ "overall_occupancy": summary.get("overall_occupancy"),
212
+ "hotspots": [h["name"] for h in summary.get("hotspots", [])[:3]],
213
+ "queue_categories": queue_summary.get("categories", {}),
214
+ }
215
+
216
+ history = data.get("history", [])
217
+ response = gemini_service.get_chat_response(message, venue_context, history)
218
+
219
+ return jsonify({"response": response})
220
+
221
+
222
+ @api_bp.route("/chat/suggestions")
223
+ def chat_suggestions():
224
+ """Get chatbot quick-reply suggestions."""
225
+ from services import gemini_service
226
+ return jsonify(gemini_service.get_quick_suggestions())
227
+
228
+
229
+ # โ”€โ”€โ”€ Notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
230
+
231
+
232
+ @api_bp.route("/notifications")
233
+ def notifications():
234
+ """Get notifications for the current user."""
235
+ svc = _get_services()
236
+ role = session.get("user_role", "attendee")
237
+ limit = request.args.get("limit", 20, type=int)
238
+ return jsonify(svc["notifications"].get_alerts(role=role, limit=limit))
239
+
240
+
241
+ @api_bp.route("/notifications/unread")
242
+ def notifications_unread():
243
+ """Get unread notification count."""
244
+ svc = _get_services()
245
+ role = session.get("user_role", "attendee")
246
+ return jsonify({"count": svc["notifications"].get_unread_count(role)})
247
+
248
+
249
+ @api_bp.route("/notifications/read", methods=["POST"])
250
+ def notifications_read():
251
+ """Mark a notification as read."""
252
+ svc = _get_services()
253
+ data = request.get_json()
254
+ alert_id = data.get("id", "") if data else ""
255
+ if svc["notifications"].mark_read(alert_id):
256
+ return jsonify({"success": True})
257
+ return jsonify({"error": "Alert not found"}), 404
258
+
259
+
260
+ # โ”€โ”€โ”€ Simulator Control โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
261
+
262
+
263
+ @api_bp.route("/simulator/status")
264
+ def simulator_status():
265
+ """Get simulator status."""
266
+ svc = _get_services()
267
+ simulator = svc["simulator"]
268
+ if not simulator:
269
+ return jsonify({"error": "Simulator unavailable"}), 503
270
+ return jsonify(simulator.get_status())
271
+
272
+
273
+ @api_bp.route("/simulator/start", methods=["POST"])
274
+ def simulator_start():
275
+ """Start the simulation."""
276
+ if session.get("user_role") not in ("operator", "admin"):
277
+ return jsonify({"error": "Operator access required"}), 403
278
+ svc = _get_services()
279
+ svc["simulator"].start()
280
+ return jsonify({"success": True, "status": svc["simulator"].get_status()})
281
+
282
+
283
+ @api_bp.route("/simulator/stop", methods=["POST"])
284
+ def simulator_stop():
285
+ """Stop the simulation."""
286
+ if session.get("user_role") not in ("operator", "admin"):
287
+ return jsonify({"error": "Operator access required"}), 403
288
+ svc = _get_services()
289
+ svc["simulator"].stop()
290
+ return jsonify({"success": True, "status": svc["simulator"].get_status()})
291
+
292
+
293
+ @api_bp.route("/simulator/phase", methods=["POST"])
294
+ def simulator_phase():
295
+ """Change simulation phase."""
296
+ if session.get("user_role") not in ("operator", "admin"):
297
+ return jsonify({"error": "Operator access required"}), 403
298
+ svc = _get_services()
299
+ data = request.get_json()
300
+ phase = data.get("phase", "") if data else ""
301
+ svc["simulator"].set_phase(phase)
302
+ return jsonify({"success": True, "status": svc["simulator"].get_status()})
303
+
304
+
305
+ @api_bp.route("/simulator/speed", methods=["POST"])
306
+ def simulator_speed():
307
+ """Change simulation speed."""
308
+ if session.get("user_role") not in ("operator", "admin"):
309
+ return jsonify({"error": "Operator access required"}), 403
310
+ svc = _get_services()
311
+ data = request.get_json()
312
+ speed = float(data.get("speed", 1.0)) if data else 1.0
313
+ svc["simulator"].set_speed(speed)
314
+ return jsonify({"success": True, "status": svc["simulator"].get_status()})
315
+
316
+
317
+ # โ”€โ”€โ”€ Translation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
318
+
319
+
320
+ @api_bp.route("/translate", methods=["POST"])
321
+ def translate():
322
+ """Translate text."""
323
+ from services import translation_service
324
+
325
+ data = request.get_json()
326
+ text = data.get("text", "") if data else ""
327
+ target = data.get("target_lang", "en") if data else "en"
328
+
329
+ if not text:
330
+ return jsonify({"error": "Text required"}), 400
331
+
332
+ translated = translation_service.translate_text(text, target)
333
+ return jsonify({"translated": translated, "target_lang": target})
334
+
335
+
336
+ @api_bp.route("/languages")
337
+ def languages():
338
+ """Get supported languages."""
339
+ from services import translation_service
340
+ return jsonify(translation_service.get_supported_languages())
341
+
342
+
343
+ # โ”€โ”€โ”€ Event Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
344
+
345
+
346
+ @api_bp.route("/event")
347
+ def event_info():
348
+ """Get current event information."""
349
+ svc = _get_services()
350
+ event = svc["event"]
351
+ return jsonify(event.to_dict() if event else {})
352
+
353
+
354
+ @api_bp.route("/venue")
355
+ def venue_info():
356
+ """Get venue information."""
357
+ svc = _get_services()
358
+ venue = svc["venue"]
359
+ return jsonify(venue.to_dict() if venue else {})
blueprints/attendee.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attendee-facing routes."""
2
+
3
+ from flask import Blueprint, render_template, session, current_app
4
+
5
+ attendee_bp = Blueprint("attendee", __name__, url_prefix="/app")
6
+
7
+
8
+ def _get_services():
9
+ """Get shared services from app context."""
10
+ return {
11
+ "venue": current_app.config.get("VENUE_OBJ"),
12
+ "event": current_app.config.get("EVENT_OBJ"),
13
+ "crowd": current_app.config.get("CROWD_SERVICE"),
14
+ "queue": current_app.config.get("QUEUE_SERVICE"),
15
+ "maps": current_app.config.get("MAPS_SERVICE"),
16
+ "notifications": current_app.config.get("NOTIFICATION_SERVICE"),
17
+ }
18
+
19
+
20
+ @attendee_bp.before_request
21
+ def require_login():
22
+ """Require login for all attendee pages."""
23
+ from blueprints.auth import login_required
24
+ # Check inline to avoid decorator issues with blueprints
25
+ if "user_id" not in session:
26
+ from flask import redirect, url_for, flash
27
+ flash("Please log in to continue.", "warning")
28
+ return redirect(url_for("auth.login"))
29
+
30
+
31
+ @attendee_bp.route("/")
32
+ def home():
33
+ """Attendee home page with venue overview."""
34
+ svc = _get_services()
35
+ venue = svc["venue"]
36
+ event = svc["event"]
37
+ crowd = svc["crowd"]
38
+ queue = svc["queue"]
39
+
40
+ venue_summary = crowd.get_venue_summary(venue) if crowd else {}
41
+ queue_summary = queue.get_queue_summary() if queue else {}
42
+
43
+ return render_template(
44
+ "attendee/home.html",
45
+ venue=venue.to_dict() if venue else {},
46
+ event=event.to_dict() if event else {},
47
+ venue_summary=venue_summary,
48
+ queue_summary=queue_summary,
49
+ user_name=session.get("user_name", "Guest"),
50
+ )
51
+
52
+
53
+ @attendee_bp.route("/heatmap")
54
+ def heatmap():
55
+ """Live crowd heatmap page."""
56
+ svc = _get_services()
57
+ venue = svc["venue"]
58
+ crowd = svc["crowd"]
59
+
60
+ heatmap_data = crowd.get_crowd_flow_data(venue) if crowd else {}
61
+
62
+ return render_template(
63
+ "attendee/heatmap.html",
64
+ venue=venue.to_dict() if venue else {},
65
+ heatmap_data=heatmap_data,
66
+ maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""),
67
+ )
68
+
69
+
70
+ @attendee_bp.route("/queues")
71
+ def queues():
72
+ """Queue status and virtual queue page."""
73
+ svc = _get_services()
74
+ queue = svc["queue"]
75
+
76
+ queue_summary = queue.get_queue_summary() if queue else {}
77
+ user_tickets = queue.get_user_tickets(session.get("user_id", "")) if queue else []
78
+
79
+ return render_template(
80
+ "attendee/queues.html",
81
+ queue_summary=queue_summary,
82
+ user_tickets=user_tickets,
83
+ )
84
+
85
+
86
+ @attendee_bp.route("/navigate")
87
+ def navigate():
88
+ """Wayfinding and navigation page."""
89
+ svc = _get_services()
90
+ venue = svc["venue"]
91
+ maps_svc = svc["maps"]
92
+
93
+ pois = maps_svc.get_points_of_interest() if maps_svc else []
94
+
95
+ return render_template(
96
+ "attendee/navigate.html",
97
+ venue=venue.to_dict() if venue else {},
98
+ pois=pois,
99
+ maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""),
100
+ )
101
+
102
+
103
+ @attendee_bp.route("/profile")
104
+ def profile():
105
+ """User profile and accessibility settings."""
106
+ from services.translation_service import get_supported_languages
107
+
108
+ return render_template(
109
+ "attendee/profile.html",
110
+ user_name=session.get("user_name", "Guest"),
111
+ user_email=session.get("user_email", ""),
112
+ languages=get_supported_languages(),
113
+ )
blueprints/auth.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication blueprint with Firebase Auth (mock fallback)."""
2
+
3
+ from flask import Blueprint, render_template, request, redirect, url_for, session, flash, current_app
4
+ from services import firebase_service
5
+ import bleach
6
+ import re
7
+
8
+ auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
9
+
10
+
11
+ def _sanitize(text: str) -> str:
12
+ """Sanitize user input."""
13
+ return bleach.clean(text.strip())
14
+
15
+
16
+ def _validate_email(email: str) -> bool:
17
+ """Basic email validation."""
18
+ pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
19
+ return bool(re.match(pattern, email))
20
+
21
+
22
+ @auth_bp.route("/login", methods=["GET", "POST"])
23
+ def login():
24
+ """Login page and handler."""
25
+ if request.method == "POST":
26
+ email = _sanitize(request.form.get("email", ""))
27
+ password = request.form.get("password", "")
28
+
29
+ if not email or not password:
30
+ flash("Please fill in all fields.", "error")
31
+ return render_template("auth/login.html")
32
+
33
+ if not _validate_email(email):
34
+ flash("Please enter a valid email address.", "error")
35
+ return render_template("auth/login.html")
36
+
37
+ # Authenticate
38
+ if current_app.config.get("USE_MOCK_SERVICES"):
39
+ user = firebase_service.verify_mock_login(email, password)
40
+ else:
41
+ # TODO: Implement Firebase Auth token verification
42
+ user = firebase_service.verify_mock_login(email, password)
43
+
44
+ if user:
45
+ session["user_id"] = user["uid"]
46
+ session["user_email"] = user["email"]
47
+ session["user_name"] = user["display_name"]
48
+ session["user_role"] = user["role"]
49
+
50
+ flash(f"Welcome back, {user['display_name']}!", "success")
51
+
52
+ if user["role"] in ("operator", "admin"):
53
+ return redirect(url_for("operator.dashboard"))
54
+ return redirect(url_for("attendee.home"))
55
+ else:
56
+ flash("Invalid email or password.", "error")
57
+
58
+ return render_template("auth/login.html")
59
+
60
+
61
+ @auth_bp.route("/register", methods=["GET", "POST"])
62
+ def register():
63
+ """Registration page and handler."""
64
+ if request.method == "POST":
65
+ email = _sanitize(request.form.get("email", ""))
66
+ password = request.form.get("password", "")
67
+ confirm_password = request.form.get("confirm_password", "")
68
+ display_name = _sanitize(request.form.get("display_name", ""))
69
+
70
+ errors = []
71
+ if not email or not password or not display_name:
72
+ errors.append("Please fill in all fields.")
73
+ if not _validate_email(email):
74
+ errors.append("Please enter a valid email address.")
75
+ if len(password) < 6:
76
+ errors.append("Password must be at least 6 characters.")
77
+ if password != confirm_password:
78
+ errors.append("Passwords do not match.")
79
+
80
+ if errors:
81
+ for err in errors:
82
+ flash(err, "error")
83
+ return render_template("auth/register.html")
84
+
85
+ # Create user
86
+ user = firebase_service.create_mock_user(email, password, display_name)
87
+ if user:
88
+ session["user_id"] = user["uid"]
89
+ session["user_email"] = user["email"]
90
+ session["user_name"] = user["display_name"]
91
+ session["user_role"] = user["role"]
92
+ flash("Account created successfully! Welcome to VenueFlow.", "success")
93
+ return redirect(url_for("attendee.home"))
94
+ else:
95
+ flash("Could not create account. Please try again.", "error")
96
+
97
+ return render_template("auth/register.html")
98
+
99
+
100
+ @auth_bp.route("/logout")
101
+ def logout():
102
+ """Clear session and redirect to login."""
103
+ session.clear()
104
+ flash("You have been logged out.", "info")
105
+ return redirect(url_for("auth.login"))
106
+
107
+
108
+ # โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
109
+
110
+
111
+ def login_required(f):
112
+ """Decorator to require authentication."""
113
+ from functools import wraps
114
+
115
+ @wraps(f)
116
+ def decorated(*args, **kwargs):
117
+ if "user_id" not in session:
118
+ flash("Please log in to continue.", "warning")
119
+ return redirect(url_for("auth.login"))
120
+ return f(*args, **kwargs)
121
+
122
+ return decorated
123
+
124
+
125
+ def operator_required(f):
126
+ """Decorator to require operator role."""
127
+ from functools import wraps
128
+
129
+ @wraps(f)
130
+ def decorated(*args, **kwargs):
131
+ if "user_id" not in session:
132
+ flash("Please log in to continue.", "warning")
133
+ return redirect(url_for("auth.login"))
134
+ if session.get("user_role") not in ("operator", "admin"):
135
+ flash("Access denied. Operator privileges required.", "error")
136
+ return redirect(url_for("attendee.home"))
137
+ return f(*args, **kwargs)
138
+
139
+ return decorated
blueprints/operator.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Operator dashboard routes."""
2
+
3
+ from flask import Blueprint, render_template, session, current_app
4
+
5
+ operator_bp = Blueprint("operator", __name__, url_prefix="/operator")
6
+
7
+
8
+ def _get_services():
9
+ """Get shared services from app context."""
10
+ return {
11
+ "venue": current_app.config.get("VENUE_OBJ"),
12
+ "event": current_app.config.get("EVENT_OBJ"),
13
+ "crowd": current_app.config.get("CROWD_SERVICE"),
14
+ "queue": current_app.config.get("QUEUE_SERVICE"),
15
+ "notifications": current_app.config.get("NOTIFICATION_SERVICE"),
16
+ "simulator": current_app.config.get("SIMULATOR_OBJ"),
17
+ }
18
+
19
+
20
+ @operator_bp.before_request
21
+ def require_operator():
22
+ """Require operator role for all operator pages."""
23
+ if "user_id" not in session:
24
+ from flask import redirect, url_for, flash
25
+ flash("Please log in to continue.", "warning")
26
+ return redirect(url_for("auth.login"))
27
+ if session.get("user_role") not in ("operator", "admin"):
28
+ from flask import redirect, url_for, flash
29
+ flash("Access denied. Operator privileges required.", "error")
30
+ return redirect(url_for("attendee.home"))
31
+
32
+
33
+ @operator_bp.route("/")
34
+ @operator_bp.route("/dashboard")
35
+ def dashboard():
36
+ """Main operator dashboard."""
37
+ svc = _get_services()
38
+ venue = svc["venue"]
39
+ event = svc["event"]
40
+ crowd = svc["crowd"]
41
+ queue = svc["queue"]
42
+ simulator = svc["simulator"]
43
+
44
+ venue_summary = crowd.get_venue_summary(venue) if crowd else {}
45
+ queue_summary = queue.get_queue_summary() if queue else {}
46
+ sim_status = simulator.get_status() if simulator else {}
47
+
48
+ return render_template(
49
+ "operator/dashboard.html",
50
+ venue=venue.to_dict() if venue else {},
51
+ event=event.to_dict() if event else {},
52
+ venue_summary=venue_summary,
53
+ queue_summary=queue_summary,
54
+ sim_status=sim_status,
55
+ user_name=session.get("user_name", "Operator"),
56
+ )
57
+
58
+
59
+ @operator_bp.route("/analytics")
60
+ def analytics():
61
+ """Historical analytics view."""
62
+ svc = _get_services()
63
+ venue = svc["venue"]
64
+ crowd = svc["crowd"]
65
+
66
+ venue_summary = crowd.get_venue_summary(venue) if crowd else {}
67
+
68
+ return render_template(
69
+ "operator/analytics.html",
70
+ venue_summary=venue_summary,
71
+ )
72
+
73
+
74
+ @operator_bp.route("/alerts")
75
+ def alerts():
76
+ """Alert management view."""
77
+ svc = _get_services()
78
+ notifications = svc["notifications"]
79
+
80
+ all_alerts = notifications.get_alerts(role="operator", limit=100) if notifications else []
81
+
82
+ return render_template(
83
+ "operator/alerts.html",
84
+ alerts=all_alerts,
85
+ )
86
+
87
+
88
+ @operator_bp.route("/simulator")
89
+ def simulator_view():
90
+ """Simulation control panel."""
91
+ svc = _get_services()
92
+ simulator = svc["simulator"]
93
+ event = svc["event"]
94
+
95
+ from models.event import EventPhase
96
+ phases = [{"value": p.value, "label": p.label, "desc": p.description} for p in EventPhase]
97
+
98
+ sim_status = simulator.get_status() if simulator else {}
99
+
100
+ return render_template(
101
+ "operator/simulator.html",
102
+ sim_status=sim_status,
103
+ event=event.to_dict() if event else {},
104
+ phases=phases,
105
+ )
blueprints/sse.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server-Sent Events (SSE) endpoint for real-time push updates."""
2
+
3
+ import json
4
+ import time
5
+ import queue
6
+ import logging
7
+ from flask import Blueprint, Response, session, current_app
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ sse_bp = Blueprint("sse", __name__, url_prefix="/sse")
12
+
13
+ # Thread-safe message queues for SSE clients
14
+ _client_queues = {}
15
+
16
+
17
+ def broadcast_update(data: dict, event_type: str = "update"):
18
+ """Send an update to all connected SSE clients."""
19
+ message = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
20
+ dead_clients = []
21
+
22
+ for client_id, q in _client_queues.items():
23
+ try:
24
+ q.put_nowait(message)
25
+ except queue.Full:
26
+ dead_clients.append(client_id)
27
+
28
+ # Clean up dead clients
29
+ for cid in dead_clients:
30
+ _client_queues.pop(cid, None)
31
+
32
+
33
+ def _generate_events(client_id: str):
34
+ """Generator for SSE events."""
35
+ q = queue.Queue(maxsize=50)
36
+ _client_queues[client_id] = q
37
+
38
+ try:
39
+ while True:
40
+ try:
41
+ # Wait for a message (with timeout for heartbeat)
42
+ message = q.get(timeout=15)
43
+ yield message
44
+ except queue.Empty:
45
+ # Send heartbeat to keep connection alive
46
+ yield f"event: heartbeat\ndata: {json.dumps({'time': time.time()})}\n\n"
47
+ except GeneratorExit:
48
+ pass
49
+ finally:
50
+ _client_queues.pop(client_id, None)
51
+
52
+
53
+ @sse_bp.route("/stream")
54
+ def stream():
55
+ """SSE stream endpoint for real-time updates."""
56
+ user_id = session.get("user_id", "anon")
57
+ client_id = f"{user_id}_{time.time()}"
58
+
59
+ return Response(
60
+ _generate_events(client_id),
61
+ mimetype="text/event-stream",
62
+ headers={
63
+ "Cache-Control": "no-cache",
64
+ "Connection": "keep-alive",
65
+ "X-Accel-Buffering": "no",
66
+ },
67
+ )
config.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration loaded from environment variables."""
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+
9
+ class Config:
10
+ """Base configuration."""
11
+
12
+ SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "dev-secret-key-change-in-production")
13
+ WTF_CSRF_ENABLED = True
14
+
15
+ # Firebase
16
+ FIREBASE_CREDENTIALS_PATH = os.getenv("FIREBASE_CREDENTIALS_PATH", "")
17
+ FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "")
18
+ FIREBASE_AUTH_DOMAIN = os.getenv("FIREBASE_AUTH_DOMAIN", "")
19
+ FIREBASE_PROJECT_ID = os.getenv("FIREBASE_PROJECT_ID", "")
20
+ FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "")
21
+ FIREBASE_MESSAGING_SENDER_ID = os.getenv("FIREBASE_MESSAGING_SENDER_ID", "")
22
+ FIREBASE_APP_ID = os.getenv("FIREBASE_APP_ID", "")
23
+
24
+ # Google Gemini
25
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
26
+
27
+ # Google Maps
28
+ GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "")
29
+
30
+ # Google Cloud Translation
31
+ GOOGLE_TRANSLATE_API_KEY = os.getenv("GOOGLE_TRANSLATE_API_KEY", "")
32
+
33
+ # App
34
+ USE_MOCK_SERVICES = os.getenv("USE_MOCK_SERVICES", "true").lower() == "true"
35
+ VENUE_CAPACITY = int(os.getenv("VENUE_CAPACITY", "50000"))
36
+
37
+
38
+ class DevelopmentConfig(Config):
39
+ """Development configuration."""
40
+
41
+ DEBUG = True
42
+ TESTING = False
43
+
44
+
45
+ class ProductionConfig(Config):
46
+ """Production configuration."""
47
+
48
+ DEBUG = False
49
+ TESTING = False
50
+ USE_MOCK_SERVICES = False
51
+
52
+
53
+ class TestingConfig(Config):
54
+ """Testing configuration."""
55
+
56
+ TESTING = True
57
+ WTF_CSRF_ENABLED = False
58
+ USE_MOCK_SERVICES = True
59
+
60
+
61
+ config_map = {
62
+ "development": DevelopmentConfig,
63
+ "production": ProductionConfig,
64
+ "testing": TestingConfig,
65
+ }
66
+
67
+
68
+ def get_config():
69
+ """Return config class based on FLASK_ENV."""
70
+ env = os.getenv("FLASK_ENV", "development")
71
+ return config_map.get(env, DevelopmentConfig)
implementation_plan.md ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VenueFlow โ€” Smart Sporting Venue Experience Platform
2
+
3
+ A Flask-based real-time system that transforms the physical event experience at large-scale sporting venues by addressing crowd movement, waiting times, and real-time coordination.
4
+
5
+ ---
6
+
7
+ ## Problem Statement
8
+
9
+ Attendees at large sporting events face:
10
+ - **Crowd congestion** at gates, concourses, and exits
11
+ - **Long queues** at food stalls, merchandise, and restrooms
12
+ - **Poor wayfinding** inside massive, unfamiliar venues
13
+ - **No real-time information** about crowd conditions or wait times
14
+ - **Accessibility barriers** for attendees with disabilities
15
+
16
+ VenueFlow solves these with an intelligent, real-time web platform for both **fans** (mobile-first attendee app) and **operators** (venue management dashboard).
17
+
18
+ ---
19
+
20
+ ## Architecture Overview
21
+
22
+ ```
23
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
24
+ โ”‚ FRONTEND (Jinja2 + JS) โ”‚
25
+ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
26
+ โ”‚ โ”‚ Attendeeโ”‚ โ”‚ Operator โ”‚ โ”‚ Heatmap โ”‚ โ”‚ Chatbot โ”‚ โ”‚
27
+ โ”‚ โ”‚ App โ”‚ โ”‚Dashboard โ”‚ โ”‚ View โ”‚ โ”‚ Widget โ”‚ โ”‚
28
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
29
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
30
+ โ”‚ โ”‚ SSE / Fetch โ”‚
31
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
32
+ โ”‚ FLASK BACKEND โ”‚
33
+ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
34
+ โ”‚ โ”‚ Auth & โ”‚ โ”‚ Real-time โ”‚ โ”‚ AI Engine โ”‚ โ”‚
35
+ โ”‚ โ”‚ Security โ”‚ โ”‚ Engine โ”‚ โ”‚ (Gemini) โ”‚ โ”‚
36
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
37
+ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
38
+ โ”‚ โ”‚ Queue โ”‚ โ”‚ Crowd โ”‚ โ”‚ Notification โ”‚ โ”‚
39
+ โ”‚ โ”‚ Manager โ”‚ โ”‚ Analytics โ”‚ โ”‚ Service โ”‚ โ”‚
40
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
41
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
42
+ โ”‚ GOOGLE SERVICES โ”‚
43
+ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
44
+ โ”‚ โ”‚ Firebase โ”‚ โ”‚Google Maps โ”‚ โ”‚ Gemini โ”‚ โ”‚
45
+ โ”‚ โ”‚ Firestoreโ”‚ โ”‚ JS API โ”‚ โ”‚ 2.5 API โ”‚ โ”‚
46
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
47
+ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
48
+ โ”‚ โ”‚ Cloud โ”‚ โ”‚ Firebase โ”‚ โ”‚
49
+ โ”‚ โ”‚Translate โ”‚ โ”‚ Auth โ”‚ โ”‚
50
+ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
51
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
52
+ ```
53
+
54
+ ### Tech Stack
55
+
56
+ | Layer | Technology |
57
+ |-------|-----------|
58
+ | **Backend** | Python 3.11+, Flask, Flask-SocketIO |
59
+ | **Frontend** | Jinja2 templates, Vanilla JS, CSS3 |
60
+ | **Database** | Firebase Firestore (real-time sync) |
61
+ | **Auth** | Firebase Authentication |
62
+ | **Maps** | Google Maps JavaScript API |
63
+ | **AI** | Google Gemini 2.5 Flash API |
64
+ | **Translation** | Google Cloud Translation API |
65
+ | **Real-time** | Server-Sent Events (SSE) + Firebase listeners |
66
+ | **Deployment** | Docker-ready for Cloud Run |
67
+
68
+ ---
69
+
70
+ ## User Review Required
71
+
72
+ > [!IMPORTANT]
73
+ > **Google API Keys Required**: You will need to provide API keys / service accounts for:
74
+ > - Firebase project (Firestore + Auth)
75
+ > - Google Maps JavaScript API key
76
+ > - Google Gemini API key
77
+ > - Google Cloud Translation API key
78
+ >
79
+ > These will be stored in a `.env` file (never committed to git).
80
+
81
+ > [!WARNING]
82
+ > **Simulated Sensor Data**: Since we don't have physical IoT sensors, the system will include a **simulation engine** that generates realistic crowd density, queue lengths, and movement patterns. This makes the demo fully functional without hardware.
83
+
84
+ > [!IMPORTANT]
85
+ > **Two User Interfaces**:
86
+ > 1. **Attendee App** (mobile-first) โ€” For fans at the venue
87
+ > 2. **Operator Dashboard** โ€” For venue staff/management
88
+ >
89
+ > Both are served from the same Flask app. Please confirm this dual-interface approach.
90
+
91
+ ---
92
+
93
+ ## Proposed Changes
94
+
95
+ ### Project Structure
96
+
97
+ ```
98
+ EventManager/
99
+ โ”œโ”€โ”€ app.py # Flask application factory
100
+ โ”œโ”€โ”€ config.py # Configuration & env management
101
+ โ”œโ”€โ”€ requirements.txt # Python dependencies
102
+ โ”œโ”€โ”€ Dockerfile # Container deployment
103
+ โ”œโ”€โ”€ .env.example # Environment variable template
104
+ โ”œโ”€โ”€ .gitignore
105
+ โ”‚
106
+ โ”œโ”€โ”€ blueprints/
107
+ โ”‚ โ”œโ”€โ”€ __init__.py
108
+ โ”‚ โ”œโ”€โ”€ auth.py # Authentication routes
109
+ โ”‚ โ”œโ”€โ”€ attendee.py # Attendee-facing routes
110
+ โ”‚ โ”œโ”€โ”€ operator.py # Operator dashboard routes
111
+ โ”‚ โ”œโ”€โ”€ api.py # REST API endpoints
112
+ โ”‚ โ””โ”€โ”€ sse.py # Server-Sent Events stream
113
+ โ”‚
114
+ โ”œโ”€โ”€ services/
115
+ โ”‚ โ”œโ”€โ”€ __init__.py
116
+ โ”‚ โ”œโ”€โ”€ firebase_service.py # Firebase Firestore & Auth
117
+ โ”‚ โ”œโ”€โ”€ crowd_service.py # Crowd density analytics
118
+ โ”‚ โ”œโ”€โ”€ queue_service.py # Queue management logic
119
+ โ”‚ โ”œโ”€โ”€ gemini_service.py # AI chatbot & recommendations
120
+ โ”‚ โ”œโ”€โ”€ maps_service.py # Maps & wayfinding
121
+ โ”‚ โ”œโ”€โ”€ translation_service.py # Multi-language support
122
+ โ”‚ โ”œโ”€โ”€ notification_service.py # Alert & notification engine
123
+ โ”‚ โ””โ”€โ”€ simulator.py # Realistic data simulation
124
+ โ”‚
125
+ โ”œโ”€โ”€ models/
126
+ โ”‚ โ”œโ”€โ”€ __init__.py
127
+ โ”‚ โ”œโ”€โ”€ venue.py # Venue, Zone, Gate models
128
+ โ”‚ โ”œโ”€โ”€ queue.py # Queue & wait time models
129
+ โ”‚ โ””โ”€โ”€ event.py # Event & attendee models
130
+ โ”‚
131
+ โ”œโ”€โ”€ static/
132
+ โ”‚ โ”œโ”€โ”€ css/
133
+ โ”‚ โ”‚ โ”œโ”€โ”€ base.css # Design system & tokens
134
+ โ”‚ โ”‚ โ”œโ”€โ”€ attendee.css # Attendee app styles
135
+ โ”‚ โ”‚ โ”œโ”€โ”€ operator.css # Operator dashboard styles
136
+ โ”‚ โ”‚ โ””โ”€โ”€ accessibility.css # A11y overrides
137
+ โ”‚ โ”œโ”€โ”€ js/
138
+ โ”‚ โ”‚ โ”œโ”€โ”€ app.js # Core JS utilities
139
+ โ”‚ โ”‚ โ”œโ”€โ”€ heatmap.js # Crowd heatmap rendering
140
+ โ”‚ โ”‚ โ”œโ”€โ”€ queue.js # Queue display & updates
141
+ โ”‚ โ”‚ โ”œโ”€โ”€ wayfinding.js # Navigation & routing
142
+ โ”‚ โ”‚ โ”œโ”€โ”€ chatbot.js # Gemini chatbot widget
143
+ โ”‚ โ”‚ โ”œโ”€โ”€ notifications.js # Real-time notification handler
144
+ โ”‚ โ”‚ โ”œโ”€โ”€ sse-client.js # SSE connection manager
145
+ โ”‚ โ”‚ โ”œโ”€โ”€ dashboard.js # Operator dashboard logic
146
+ โ”‚ โ”‚ โ”œโ”€โ”€ simulator-ui.js # Simulation controls
147
+ โ”‚ โ”‚ โ””โ”€โ”€ accessibility.js # A11y toggle & preferences
148
+ โ”‚ โ””โ”€โ”€ images/
149
+ โ”‚ โ””โ”€โ”€ (generated assets)
150
+ โ”‚
151
+ โ”œโ”€โ”€ templates/
152
+ โ”‚ โ”œโ”€โ”€ base.html # Base template with a11y
153
+ โ”‚ โ”œโ”€โ”€ attendee/
154
+ โ”‚ โ”‚ โ”œโ”€โ”€ home.html # Fan landing page
155
+ โ”‚ โ”‚ โ”œโ”€โ”€ heatmap.html # Live crowd heatmap
156
+ โ”‚ โ”‚ โ”œโ”€โ”€ queues.html # Queue status & booking
157
+ โ”‚ โ”‚ โ”œโ”€โ”€ navigate.html # Wayfinding page
158
+ โ”‚ โ”‚ โ””โ”€โ”€ profile.html # Preferences & a11y settings
159
+ โ”‚ โ”œโ”€โ”€ operator/
160
+ โ”‚ โ”‚ โ”œโ”€โ”€ dashboard.html # Main control dashboard
161
+ โ”‚ โ”‚ โ”œโ”€โ”€ analytics.html # Historical analytics
162
+ โ”‚ โ”‚ โ”œโ”€โ”€ alerts.html # Alert management
163
+ โ”‚ โ”‚ โ””โ”€โ”€ simulator.html # Simulation controls
164
+ โ”‚ โ”œโ”€โ”€ auth/
165
+ โ”‚ โ”‚ โ”œโ”€โ”€ login.html
166
+ โ”‚ โ”‚ โ””โ”€โ”€ register.html
167
+ โ”‚ โ””โ”€โ”€ components/
168
+ โ”‚ โ”œโ”€โ”€ chatbot.html # Chatbot widget partial
169
+ โ”‚ โ”œโ”€โ”€ notification.html # Notification toast partial
170
+ โ”‚ โ””โ”€โ”€ nav.html # Navigation component
171
+ โ”‚
172
+ โ””โ”€โ”€ tests/
173
+ โ”œโ”€โ”€ __init__.py
174
+ โ”œโ”€โ”€ conftest.py # Pytest fixtures
175
+ โ”œโ”€โ”€ test_auth.py # Authentication tests
176
+ โ”œโ”€โ”€ test_crowd_service.py # Crowd analytics tests
177
+ โ”œโ”€โ”€ test_queue_service.py # Queue management tests
178
+ โ”œโ”€โ”€ test_api.py # API endpoint tests
179
+ โ”œโ”€โ”€ test_simulator.py # Simulator logic tests
180
+ โ””โ”€โ”€ test_accessibility.py # Accessibility audit tests
181
+ ```
182
+
183
+ ---
184
+
185
+ ### Component 1: Core Application & Configuration
186
+
187
+ #### [NEW] `app.py`
188
+ - Flask application factory pattern with blueprint registration
189
+ - CORS, CSP, and security headers middleware
190
+ - Error handlers (404, 500) with accessible error pages
191
+ - SSE stream endpoint registration
192
+
193
+ #### [NEW] `config.py`
194
+ - Environment-based configuration (dev/staging/prod)
195
+ - Secure loading of all API keys from `.env`
196
+ - Firebase, Gemini, Maps, Translation config constants
197
+
198
+ #### [NEW] `requirements.txt`
199
+ Key dependencies:
200
+ ```
201
+ flask>=3.1
202
+ flask-socketio>=5.3
203
+ firebase-admin>=6.5
204
+ google-generativeai>=0.8
205
+ google-cloud-translate>=3.16
206
+ gunicorn>=22.0
207
+ python-dotenv>=1.0
208
+ pytest>=8.0
209
+ ```
210
+
211
+ ---
212
+
213
+ ### Component 2: Authentication (Firebase Auth)
214
+
215
+ #### [NEW] `blueprints/auth.py`
216
+ - Login / Register routes using Firebase Authentication
217
+ - Role-based access: `attendee` vs `operator`
218
+ - Session management with secure HTTP-only cookies
219
+ - CSRF protection on all forms
220
+
221
+ #### [NEW] `services/firebase_service.py`
222
+ - Firebase Admin SDK initialization (singleton)
223
+ - Firestore CRUD helpers with connection pooling
224
+ - Real-time listener setup for zone/queue collections
225
+ - Token verification for authenticated API calls
226
+
227
+ ---
228
+
229
+ ### Component 3: Real-Time Crowd Heatmap
230
+
231
+ #### [NEW] `services/crowd_service.py`
232
+ - Processes zone density data from Firestore
233
+ - Calculates crowd density levels: ๐ŸŸข Low / ๐ŸŸก Moderate / ๐Ÿ”ด High / โšซ Critical
234
+ - Threshold-based alerting triggers
235
+ - Historical trend computation
236
+
237
+ #### [NEW] `static/js/heatmap.js`
238
+ - Renders interactive venue map overlay using **Google Maps JavaScript API**
239
+ - Real-time heatmap layer with color-coded zones
240
+ - Click-to-inspect zone details (capacity, current count, trend)
241
+ - Auto-refresh via SSE stream
242
+
243
+ #### [NEW] `templates/attendee/heatmap.html`
244
+ - Mobile-first responsive layout
245
+ - Legend with WCAG-compliant color indicators + text labels
246
+ - "Best route" suggestion panel
247
+ - Accessible alternative: tabular zone status view
248
+
249
+ ---
250
+
251
+ ### Component 4: Queue Management System
252
+
253
+ #### [NEW] `services/queue_service.py`
254
+ - Manages virtual queues for food, merchandise, restrooms
255
+ - Estimated wait time calculation (moving average algorithm)
256
+ - "Book your spot" โ€” virtual queue reservation
257
+ - Queue position tracking and SMS/notification alerts
258
+
259
+ #### [NEW] `static/js/queue.js`
260
+ - Live queue dashboard with animated progress bars
261
+ - Sort/filter by wait time, category, proximity
262
+ - "Join Queue" button with confirmation modal
263
+ - Real-time position counter
264
+
265
+ #### [NEW] `templates/attendee/queues.html`
266
+ - Card-based queue listings with status indicators
267
+ - Category filters (๐Ÿ• Food, ๐Ÿ› Merch, ๐Ÿšป Restrooms)
268
+ - Estimated wait time with confidence indicator
269
+ - Virtual queue ticket with QR code
270
+
271
+ ---
272
+
273
+ ### Component 5: Wayfinding & Navigation
274
+
275
+ #### [NEW] `services/maps_service.py`
276
+ - Google Maps integration for venue layout
277
+ - Shortest-path routing between venue zones
278
+ - Accessibility-aware routing (elevators, ramps)
279
+ - Points-of-interest data management
280
+
281
+ #### [NEW] `static/js/wayfinding.js`
282
+ - Interactive venue map with Google Maps JS API
283
+ - Turn-by-turn directions within the venue
284
+ - "Navigate to nearest [restroom/food/exit]" functionality
285
+ - Crowd-aware routing (avoids congested paths)
286
+
287
+ #### [NEW] `templates/attendee/navigate.html`
288
+ - Full-screen map with controls
289
+ - Quick-action buttons: "Nearest Exit", "My Seat", "Food Court"
290
+ - Step-by-step accessible text directions
291
+ - Estimated walking time display
292
+
293
+ ---
294
+
295
+ ### Component 6: AI Chatbot (Google Gemini)
296
+
297
+ #### [NEW] `services/gemini_service.py`
298
+ - Gemini 2.5 Flash integration for conversational AI
299
+ - Context-aware: knows venue layout, current crowd data, queue times
300
+ - Multi-language support via Cloud Translation API
301
+ - Safety filters and responsible AI guardrails
302
+ - Prompts:
303
+ - "Where's the shortest food queue?"
304
+ - "How do I get to Gate 7?"
305
+ - "Is Section B crowded right now?"
306
+
307
+ #### [NEW] `static/js/chatbot.js`
308
+ - Floating chat widget (bottom-right)
309
+ - Message bubbles with typing indicator
310
+ - Quick-reply suggestion chips
311
+ - Language selector for real-time translation
312
+ - ARIA live region for screen reader announcements
313
+
314
+ #### [NEW] `templates/components/chatbot.html`
315
+ - Accessible chat interface with proper ARIA roles
316
+ - Resizable/minimizable widget
317
+ - Keyboard-navigable message list
318
+
319
+ ---
320
+
321
+ ### Component 7: Operator Dashboard
322
+
323
+ #### [NEW] `blueprints/operator.py`
324
+ - Protected routes (operator role required)
325
+ - Dashboard, analytics, alerts, and simulator views
326
+ - API endpoints for crowd threshold management
327
+
328
+ #### [NEW] `static/js/dashboard.js`
329
+ - Real-time KPI cards (total attendance, avg wait, alerts)
330
+ - Zone-by-zone capacity meters
331
+ - Alert timeline with severity levels
332
+ - Staff deployment recommendations
333
+
334
+ #### [NEW] `templates/operator/dashboard.html`
335
+ - Grid layout with responsive breakpoints
336
+ - Live charts (crowd trends, queue throughput)
337
+ - Draggable alert cards
338
+ - Emergency broadcast panel
339
+
340
+ ---
341
+
342
+ ### Component 8: Notification & Alert System
343
+
344
+ #### [NEW] `services/notification_service.py`
345
+ - Multi-channel: in-app toasts, SSE push, email
346
+ - Priority levels: Info / Warning / Critical / Emergency
347
+ - Geo-targeted: alerts to specific venue zones
348
+ - Automatic triggers from crowd density thresholds
349
+
350
+ #### [NEW] `blueprints/sse.py`
351
+ - Server-Sent Events endpoint for real-time push
352
+ - Per-user event filtering (zone, role)
353
+ - Heartbeat keep-alive for connection stability
354
+ - Graceful reconnection handling
355
+
356
+ #### [NEW] `static/js/notifications.js`
357
+ - Toast notification stack (top-right)
358
+ - Sound alerts for critical notifications
359
+ - Notification center with history
360
+ - "Do Not Disturb" mode for operators
361
+
362
+ ---
363
+
364
+ ### Component 9: Simulation Engine
365
+
366
+ #### [NEW] `services/simulator.py`
367
+ - Generates realistic crowd movement patterns
368
+ - Simulates event lifecycle: pre-event โ†’ kickoff โ†’ halftime โ†’ post-event
369
+ - Queue fluctuation with realistic distributions
370
+ - Configurable parameters: venue capacity, event type, weather
371
+ - Writes simulated data to Firestore in real-time
372
+
373
+ #### [NEW] `templates/operator/simulator.html`
374
+ - Simulation control panel (start/stop/speed)
375
+ - Event phase selector
376
+ - Parameter sliders (crowd size, arrival rate)
377
+ - Live preview of simulated data
378
+
379
+ ---
380
+
381
+ ### Component 10: Accessibility & Internationalization
382
+
383
+ #### [NEW] `static/css/accessibility.css`
384
+ - High-contrast mode styles
385
+ - Large text mode (150% scaling)
386
+ - Reduced motion preference support
387
+ - Focus indicator styles (3px solid, 3:1 contrast)
388
+
389
+ #### [NEW] `static/js/accessibility.js`
390
+ - Accessibility preferences panel
391
+ - Toggle: high contrast, large text, reduced motion
392
+ - Persisted via localStorage
393
+ - Screen reader announcement utility
394
+
395
+ #### [NEW] `services/translation_service.py`
396
+ - Google Cloud Translation API integration
397
+ - Auto-detect user language from browser
398
+ - On-demand translation of UI strings and notifications
399
+ - Cached translations for performance
400
+
401
+ ---
402
+
403
+ ## Google Services Integration Summary
404
+
405
+ | Google Service | Usage | Why |
406
+ |---|---|---|
407
+ | **Firebase Firestore** | Real-time database for crowd, queue, and event data | Sub-second sync, offline support, scales automatically |
408
+ | **Firebase Auth** | User authentication (email/social login) | Secure, supports role-based access, easy integration |
409
+ | **Google Maps JS API** | Venue map, heatmap overlay, wayfinding routes | Industry standard mapping, custom overlays, directions |
410
+ | **Gemini 2.5 Flash** | AI chatbot for attendee assistance | Context-aware responses, fast inference, multi-turn |
411
+ | **Cloud Translation** | Multi-language support | Real-time translation for international events |
412
+
413
+ ---
414
+
415
+ ## Security Implementation
416
+
417
+ 1. **Authentication**: Firebase Auth with JWT token verification
418
+ 2. **Authorization**: Role-based access control (attendee/operator/admin)
419
+ 3. **CSRF**: Flask-WTF CSRF tokens on all forms
420
+ 4. **CSP**: Content Security Policy headers
421
+ 5. **Input Validation**: Server-side validation on all endpoints
422
+ 6. **Rate Limiting**: API rate limiting to prevent abuse
423
+ 7. **Secrets Management**: All keys in `.env`, never in source
424
+ 8. **HTTPS**: Enforced in production via Dockerfile/Cloud Run
425
+ 9. **XSS Protection**: Jinja2 auto-escaping enabled
426
+ 10. **SQL Injection**: N/A (NoSQL Firestore) โ€” Firestore Security Rules used
427
+
428
+ ---
429
+
430
+ ## Accessibility Compliance (WCAG 2.2 AA)
431
+
432
+ - **Contrast**: All text โ‰ฅ 4.5:1 ratio, UI components โ‰ฅ 3:1
433
+ - **Target Size**: All interactive elements โ‰ฅ 44ร—44 CSS pixels
434
+ - **Keyboard Navigation**: Full keyboard support with visible focus indicators
435
+ - **Screen Readers**: ARIA landmarks, live regions, proper heading hierarchy
436
+ - **Reduced Motion**: `prefers-reduced-motion` media query support
437
+ - **High Contrast Mode**: Toggle-able high contrast theme
438
+ - **Skip Links**: "Skip to content" link on every page
439
+ - **Semantic HTML**: Proper use of `<nav>`, `<main>`, `<aside>`, `<section>`
440
+ - **Alt Text**: All images have descriptive alt text
441
+ - **Language**: `lang` attribute on `<html>`, translated content marked
442
+
443
+ ---
444
+
445
+ ## Testing Strategy
446
+
447
+ ### Unit Tests (pytest)
448
+ ```
449
+ tests/
450
+ โ”œโ”€โ”€ test_crowd_service.py # Density calculation, threshold logic
451
+ โ”œโ”€โ”€ test_queue_service.py # Wait time estimation, virtual queue
452
+ โ”œโ”€โ”€ test_auth.py # Login, registration, role checks
453
+ โ”œโ”€โ”€ test_api.py # API endpoint responses
454
+ โ””โ”€โ”€ test_simulator.py # Simulation output validation
455
+ ```
456
+
457
+ ### Integration Tests
458
+ - Firebase Firestore read/write operations
459
+ - Gemini API response processing
460
+ - SSE event delivery end-to-end
461
+
462
+ ### Accessibility Tests
463
+ - Automated: axe-core audit via browser subagent
464
+ - Manual: keyboard navigation flow verification
465
+ - Contrast ratio verification on all pages
466
+
467
+ ### Browser Testing
468
+ - Mobile viewport (375px) responsive validation
469
+ - Desktop viewport (1440px) dashboard verification
470
+ - Cross-browser visual spot checks
471
+
472
+ ---
473
+
474
+ ## Verification Plan
475
+
476
+ ### Automated Tests
477
+ 1. `pytest tests/ -v` โ€” Run full test suite
478
+ 2. `flask run` โ€” Verify app starts without errors
479
+ 3. Browser subagent: Navigate all pages, verify rendering
480
+ 4. Accessibility audit via axe-core integration
481
+
482
+ ### Manual Verification
483
+ 1. Start simulation โ†’ observe real-time heatmap updates
484
+ 2. Join virtual queue โ†’ verify position tracking
485
+ 3. Ask chatbot questions โ†’ verify Gemini responses
486
+ 4. Toggle accessibility modes โ†’ verify contrast/text changes
487
+ 5. Switch languages โ†’ verify translation
488
+
489
+ ---
490
+
491
+ ## Execution Phases
492
+
493
+ ### Phase 1: Foundation (Core + Auth + Database)
494
+ - `app.py`, `config.py`, `requirements.txt`
495
+ - Firebase service, Auth blueprints
496
+ - Base templates with design system
497
+ - Docker setup
498
+
499
+ ### Phase 2: Real-Time Core (Heatmap + Queues + SSE)
500
+ - Crowd service + heatmap visualization
501
+ - Queue management system
502
+ - SSE real-time push infrastructure
503
+ - Simulation engine
504
+
505
+ ### Phase 3: Intelligence (AI + Navigation + Notifications)
506
+ - Gemini chatbot integration
507
+ - Google Maps wayfinding
508
+ - Notification/alert system
509
+ - Cloud Translation integration
510
+
511
+ ### Phase 4: Operations (Dashboard + Analytics)
512
+ - Operator dashboard with live KPIs
513
+ - Historical analytics views
514
+ - Alert management interface
515
+ - Simulation control panel
516
+
517
+ ### Phase 5: Polish (A11y + Testing + Security)
518
+ - Accessibility features & compliance
519
+ - Comprehensive test suite
520
+ - Security hardening
521
+ - Performance optimization
522
+ - Final UI polish
523
+
524
+ ---
525
+
526
+ ## Open Questions
527
+
528
+ > [!IMPORTANT]
529
+ > 1. **Do you have Google Cloud / Firebase API keys ready?** If not, I can set up the project with mock services that can be swapped for real ones later.
530
+
531
+ > [!IMPORTANT]
532
+ > 2. **Venue type preference?** Should the demo simulate a specific type of venue (cricket stadium, football stadium, Olympic arena)? This affects the venue map layout.
533
+
534
+ > [!NOTE]
535
+ > 3. **Should we include a mobile app wrapper** or keep this strictly as a responsive web app? (I recommend responsive web โ€” works on all devices without installation.)
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Data models package."""
models/event.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event and attendee models."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+ from enum import Enum
7
+
8
+
9
+ class EventPhase(Enum):
10
+ """Current phase of the event lifecycle."""
11
+
12
+ PRE_EVENT = "pre_event"
13
+ GATES_OPEN = "gates_open"
14
+ FILLING = "filling"
15
+ EVENT_START = "event_start"
16
+ FIRST_HALF = "first_half"
17
+ HALFTIME = "halftime"
18
+ SECOND_HALF = "second_half"
19
+ EVENT_END = "event_end"
20
+ EXITING = "exiting"
21
+ POST_EVENT = "post_event"
22
+
23
+ @property
24
+ def label(self):
25
+ return {
26
+ "pre_event": "Pre-Event",
27
+ "gates_open": "Gates Open",
28
+ "filling": "Venue Filling",
29
+ "event_start": "Event Started",
30
+ "first_half": "1st Half / Session",
31
+ "halftime": "Half-Time / Break",
32
+ "second_half": "2nd Half / Session",
33
+ "event_end": "Event Ended",
34
+ "exiting": "Attendees Exiting",
35
+ "post_event": "Post-Event",
36
+ }[self.value]
37
+
38
+ @property
39
+ def description(self):
40
+ return {
41
+ "pre_event": "Venue preparation underway",
42
+ "gates_open": "Gates are open, attendees arriving",
43
+ "filling": "Venue is filling up rapidly",
44
+ "event_start": "The event has begun",
45
+ "first_half": "First session in progress",
46
+ "halftime": "Break time โ€” high food/restroom traffic expected",
47
+ "second_half": "Second session in progress",
48
+ "event_end": "Event concluded โ€” exit rush expected",
49
+ "exiting": "Attendees leaving the venue",
50
+ "post_event": "Venue cleared",
51
+ }[self.value]
52
+
53
+
54
+ @dataclass
55
+ class Event:
56
+ """Represents a sporting event."""
57
+
58
+ id: str
59
+ name: str
60
+ sport: str
61
+ venue_id: str
62
+ date: datetime
63
+ home_team: str = ""
64
+ away_team: str = ""
65
+ current_phase: str = "pre_event"
66
+ expected_attendance: int = 0
67
+ actual_attendance: int = 0
68
+ weather: str = "clear"
69
+ temperature_c: float = 28.0
70
+
71
+ @property
72
+ def phase(self) -> EventPhase:
73
+ return EventPhase(self.current_phase)
74
+
75
+ def to_dict(self) -> dict:
76
+ return {
77
+ "id": self.id,
78
+ "name": self.name,
79
+ "sport": self.sport,
80
+ "venue_id": self.venue_id,
81
+ "date": self.date.isoformat(),
82
+ "home_team": self.home_team,
83
+ "away_team": self.away_team,
84
+ "current_phase": self.current_phase,
85
+ "phase_label": self.phase.label,
86
+ "phase_description": self.phase.description,
87
+ "expected_attendance": self.expected_attendance,
88
+ "actual_attendance": self.actual_attendance,
89
+ "weather": self.weather,
90
+ "temperature_c": self.temperature_c,
91
+ }
92
+
93
+
94
+ @dataclass
95
+ class Attendee:
96
+ """An event attendee / user profile."""
97
+
98
+ id: str
99
+ display_name: str
100
+ email: str
101
+ role: str = "attendee" # attendee, operator, admin
102
+ language: str = "en"
103
+ accessibility_needs: List[str] = field(default_factory=list)
104
+ current_zone_id: Optional[str] = None
105
+ seat_section: str = ""
106
+ seat_row: str = ""
107
+ seat_number: str = ""
108
+ preferences: dict = field(default_factory=dict)
109
+
110
+ @property
111
+ def is_operator(self) -> bool:
112
+ return self.role in ("operator", "admin")
113
+
114
+ @property
115
+ def seat_display(self) -> str:
116
+ parts = []
117
+ if self.seat_section:
118
+ parts.append(f"Section {self.seat_section}")
119
+ if self.seat_row:
120
+ parts.append(f"Row {self.seat_row}")
121
+ if self.seat_number:
122
+ parts.append(f"Seat {self.seat_number}")
123
+ return ", ".join(parts) if parts else "General Admission"
124
+
125
+ def to_dict(self) -> dict:
126
+ return {
127
+ "id": self.id,
128
+ "display_name": self.display_name,
129
+ "email": self.email,
130
+ "role": self.role,
131
+ "language": self.language,
132
+ "accessibility_needs": self.accessibility_needs,
133
+ "current_zone_id": self.current_zone_id,
134
+ "seat_display": self.seat_display,
135
+ }
models/queue.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Queue and wait-time models."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Optional
6
+ from enum import Enum
7
+ import uuid
8
+
9
+
10
+ class QueueCategory(Enum):
11
+ """Category of service queue."""
12
+
13
+ FOOD = "food"
14
+ MERCHANDISE = "merch"
15
+ RESTROOM = "restroom"
16
+ ENTRY = "entry"
17
+ EXIT = "exit"
18
+ TICKET = "ticket"
19
+
20
+ @property
21
+ def icon(self):
22
+ return {
23
+ "food": "๐Ÿ•",
24
+ "merch": "๐Ÿ›๏ธ",
25
+ "restroom": "๐Ÿšป",
26
+ "entry": "๐Ÿšช",
27
+ "exit": "๐Ÿšถ",
28
+ "ticket": "๐ŸŽซ",
29
+ }[self.value]
30
+
31
+ @property
32
+ def label(self):
33
+ return {
34
+ "food": "Food & Beverages",
35
+ "merch": "Merchandise",
36
+ "restroom": "Restrooms",
37
+ "entry": "Entry Gates",
38
+ "exit": "Exit Gates",
39
+ "ticket": "Ticket Counter",
40
+ }[self.value]
41
+
42
+
43
+ @dataclass
44
+ class QueueStation:
45
+ """A single service station (e.g., one food stall)."""
46
+
47
+ id: str
48
+ name: str
49
+ category: str # QueueCategory value
50
+ zone_id: str
51
+ current_length: int = 0
52
+ avg_service_time_sec: float = 45.0
53
+ is_open: bool = True
54
+ lat: float = 0.0
55
+ lng: float = 0.0
56
+
57
+ @property
58
+ def estimated_wait_minutes(self) -> float:
59
+ if not self.is_open or self.current_length == 0:
60
+ return 0.0
61
+ return round((self.current_length * self.avg_service_time_sec) / 60, 1)
62
+
63
+ @property
64
+ def wait_level(self) -> str:
65
+ wait = self.estimated_wait_minutes
66
+ if wait < 5:
67
+ return "short"
68
+ elif wait < 15:
69
+ return "moderate"
70
+ elif wait < 30:
71
+ return "long"
72
+ else:
73
+ return "very_long"
74
+
75
+ @property
76
+ def wait_color(self) -> str:
77
+ return {
78
+ "short": "#22c55e",
79
+ "moderate": "#f59e0b",
80
+ "long": "#ef4444",
81
+ "very_long": "#18181b",
82
+ }[self.wait_level]
83
+
84
+ def to_dict(self) -> dict:
85
+ return {
86
+ "id": self.id,
87
+ "name": self.name,
88
+ "category": self.category,
89
+ "category_icon": QueueCategory(self.category).icon,
90
+ "category_label": QueueCategory(self.category).label,
91
+ "zone_id": self.zone_id,
92
+ "current_length": self.current_length,
93
+ "estimated_wait_minutes": self.estimated_wait_minutes,
94
+ "wait_level": self.wait_level,
95
+ "wait_color": self.wait_color,
96
+ "is_open": self.is_open,
97
+ "lat": self.lat,
98
+ "lng": self.lng,
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class VirtualQueueTicket:
104
+ """A virtual queue reservation for an attendee."""
105
+
106
+ id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
107
+ user_id: str = ""
108
+ station_id: str = ""
109
+ station_name: str = ""
110
+ category: str = ""
111
+ position: int = 0
112
+ estimated_ready_time: Optional[datetime] = None
113
+ status: str = "waiting" # waiting, ready, served, expired
114
+ created_at: datetime = field(default_factory=datetime.utcnow)
115
+
116
+ def to_dict(self) -> dict:
117
+ return {
118
+ "id": self.id,
119
+ "user_id": self.user_id,
120
+ "station_id": self.station_id,
121
+ "station_name": self.station_name,
122
+ "category": self.category,
123
+ "category_icon": QueueCategory(self.category).icon if self.category else "",
124
+ "position": self.position,
125
+ "estimated_ready_time": (
126
+ self.estimated_ready_time.isoformat()
127
+ if self.estimated_ready_time
128
+ else None
129
+ ),
130
+ "status": self.status,
131
+ "created_at": self.created_at.isoformat(),
132
+ }
models/venue.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Venue and zone models for the stadium layout."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+ from enum import Enum
6
+
7
+
8
+ class DensityLevel(Enum):
9
+ """Crowd density classification."""
10
+
11
+ LOW = "low"
12
+ MODERATE = "moderate"
13
+ HIGH = "high"
14
+ CRITICAL = "critical"
15
+
16
+ @property
17
+ def color(self):
18
+ return {
19
+ "low": "#22c55e",
20
+ "moderate": "#f59e0b",
21
+ "high": "#ef4444",
22
+ "critical": "#18181b",
23
+ }[self.value]
24
+
25
+ @property
26
+ def label(self):
27
+ return {
28
+ "low": "๐ŸŸข Low",
29
+ "moderate": "๐ŸŸก Moderate",
30
+ "high": "๐Ÿ”ด High",
31
+ "critical": "โšซ Critical",
32
+ }[self.value]
33
+
34
+
35
+ @dataclass
36
+ class Zone:
37
+ """A distinct area within the venue."""
38
+
39
+ id: str
40
+ name: str
41
+ zone_type: str # 'stand', 'concourse', 'gate', 'food_court', 'restroom', 'parking'
42
+ capacity: int
43
+ current_count: int = 0
44
+ lat: float = 0.0
45
+ lng: float = 0.0
46
+ polygon_coords: List[dict] = field(default_factory=list)
47
+ amenities: List[str] = field(default_factory=list)
48
+ is_accessible: bool = True
49
+ floor_level: int = 0
50
+
51
+ @property
52
+ def occupancy_rate(self) -> float:
53
+ if self.capacity == 0:
54
+ return 0.0
55
+ return min(self.current_count / self.capacity, 1.0)
56
+
57
+ @property
58
+ def density_level(self) -> DensityLevel:
59
+ rate = self.occupancy_rate
60
+ if rate < 0.4:
61
+ return DensityLevel.LOW
62
+ elif rate < 0.7:
63
+ return DensityLevel.MODERATE
64
+ elif rate < 0.9:
65
+ return DensityLevel.HIGH
66
+ else:
67
+ return DensityLevel.CRITICAL
68
+
69
+ @property
70
+ def available_capacity(self) -> int:
71
+ return max(0, self.capacity - self.current_count)
72
+
73
+ def to_dict(self) -> dict:
74
+ return {
75
+ "id": self.id,
76
+ "name": self.name,
77
+ "zone_type": self.zone_type,
78
+ "capacity": self.capacity,
79
+ "current_count": self.current_count,
80
+ "occupancy_rate": round(self.occupancy_rate * 100, 1),
81
+ "density_level": self.density_level.value,
82
+ "density_color": self.density_level.color,
83
+ "density_label": self.density_level.label,
84
+ "available_capacity": self.available_capacity,
85
+ "lat": self.lat,
86
+ "lng": self.lng,
87
+ "polygon_coords": self.polygon_coords,
88
+ "amenities": self.amenities,
89
+ "is_accessible": self.is_accessible,
90
+ "floor_level": self.floor_level,
91
+ }
92
+
93
+
94
+ @dataclass
95
+ class Venue:
96
+ """Represents the entire sporting venue."""
97
+
98
+ id: str
99
+ name: str
100
+ city: str
101
+ total_capacity: int
102
+ zones: List[Zone] = field(default_factory=list)
103
+ center_lat: float = 0.0
104
+ center_lng: float = 0.0
105
+ map_zoom: int = 17
106
+
107
+ @property
108
+ def total_current(self) -> int:
109
+ return sum(z.current_count for z in self.zones)
110
+
111
+ @property
112
+ def overall_occupancy(self) -> float:
113
+ if self.total_capacity == 0:
114
+ return 0.0
115
+ return min(self.total_current / self.total_capacity, 1.0)
116
+
117
+ def get_zone(self, zone_id: str) -> Optional[Zone]:
118
+ for zone in self.zones:
119
+ if zone.id == zone_id:
120
+ return zone
121
+ return None
122
+
123
+ def get_zones_by_type(self, zone_type: str) -> List[Zone]:
124
+ return [z for z in self.zones if z.zone_type == zone_type]
125
+
126
+ def to_dict(self) -> dict:
127
+ return {
128
+ "id": self.id,
129
+ "name": self.name,
130
+ "city": self.city,
131
+ "total_capacity": self.total_capacity,
132
+ "total_current": self.total_current,
133
+ "overall_occupancy": round(self.overall_occupancy * 100, 1),
134
+ "center_lat": self.center_lat,
135
+ "center_lng": self.center_lng,
136
+ "map_zoom": self.map_zoom,
137
+ "zones": [z.to_dict() for z in self.zones],
138
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask>=3.1
2
+ flask-socketio>=5.3
3
+ flask-wtf>=1.2
4
+ firebase-admin>=6.5
5
+ google-generativeai>=0.8
6
+ google-cloud-translate>=3.16
7
+ gunicorn>=22.0
8
+ python-dotenv>=1.0
9
+ pytest>=8.0
10
+ pytest-flask>=1.3
11
+ requests>=2.31
12
+ bleach>=6.1
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Services package."""
services/crowd_service.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crowd density analytics service."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Optional
5
+ from models.venue import Venue, Zone, DensityLevel
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class CrowdService:
11
+ """Manages crowd density analytics and alerts."""
12
+
13
+ # Thresholds for triggering alerts (occupancy rate)
14
+ THRESHOLDS = {
15
+ "warning": 0.7,
16
+ "critical": 0.9,
17
+ "emergency": 0.95,
18
+ }
19
+
20
+ def __init__(self):
21
+ self._alert_callbacks = []
22
+ self._history: List[Dict] = []
23
+
24
+ def register_alert_callback(self, callback):
25
+ """Register a function to call when density thresholds are breached."""
26
+ self._alert_callbacks.append(callback)
27
+
28
+ def update_zone_count(self, venue: Venue, zone_id: str, new_count: int) -> Optional[Dict]:
29
+ """Update a zone's crowd count and check thresholds."""
30
+ zone = venue.get_zone(zone_id)
31
+ if not zone:
32
+ return None
33
+
34
+ old_level = zone.density_level
35
+ zone.current_count = max(0, min(new_count, zone.capacity))
36
+ new_level = zone.density_level
37
+
38
+ # Record history
39
+ self._history.append({
40
+ "zone_id": zone_id,
41
+ "count": zone.current_count,
42
+ "occupancy": zone.occupancy_rate,
43
+ "level": new_level.value,
44
+ })
45
+
46
+ # Keep history manageable (last 1000 entries)
47
+ if len(self._history) > 1000:
48
+ self._history = self._history[-500:]
49
+
50
+ # Check if we need to fire alerts
51
+ alert = None
52
+ if new_level != old_level and new_level in (DensityLevel.HIGH, DensityLevel.CRITICAL):
53
+ alert = {
54
+ "type": "crowd_density",
55
+ "severity": "critical" if new_level == DensityLevel.CRITICAL else "warning",
56
+ "zone_id": zone_id,
57
+ "zone_name": zone.name,
58
+ "occupancy_rate": round(zone.occupancy_rate * 100, 1),
59
+ "density_level": new_level.value,
60
+ "message": f"โš ๏ธ {zone.name} is now at {new_level.label} density ({round(zone.occupancy_rate * 100)}% capacity)",
61
+ }
62
+ for callback in self._alert_callbacks:
63
+ try:
64
+ callback(alert)
65
+ except Exception as e:
66
+ logger.error(f"Alert callback error: {e}")
67
+
68
+ return zone.to_dict()
69
+
70
+ def get_venue_summary(self, venue: Venue) -> Dict:
71
+ """Get a summary of crowd conditions across the venue."""
72
+ zones_data = [z.to_dict() for z in venue.zones]
73
+
74
+ # Count zones by density level
75
+ density_counts = {}
76
+ for level in DensityLevel:
77
+ density_counts[level.value] = sum(
78
+ 1 for z in venue.zones if z.density_level == level
79
+ )
80
+
81
+ # Find hotspots (highest occupancy zones)
82
+ hotspots = sorted(venue.zones, key=lambda z: z.occupancy_rate, reverse=True)[:5]
83
+
84
+ # Find available zones (lowest occupancy)
85
+ available = sorted(venue.zones, key=lambda z: z.occupancy_rate)[:5]
86
+
87
+ return {
88
+ "total_capacity": venue.total_capacity,
89
+ "total_current": venue.total_current,
90
+ "overall_occupancy": round(venue.overall_occupancy * 100, 1),
91
+ "density_counts": density_counts,
92
+ "hotspots": [z.to_dict() for z in hotspots],
93
+ "most_available": [z.to_dict() for z in available],
94
+ "zones": zones_data,
95
+ }
96
+
97
+ def get_zone_trend(self, zone_id: str, last_n: int = 20) -> List[Dict]:
98
+ """Get recent occupancy trend for a zone."""
99
+ zone_history = [h for h in self._history if h["zone_id"] == zone_id]
100
+ return zone_history[-last_n:]
101
+
102
+ def get_recommended_zones(self, venue: Venue, zone_type: str = None) -> List[Dict]:
103
+ """Recommend least crowded zones, optionally filtered by type."""
104
+ zones = venue.zones
105
+ if zone_type:
106
+ zones = [z for z in zones if z.zone_type == zone_type]
107
+
108
+ sorted_zones = sorted(zones, key=lambda z: z.occupancy_rate)
109
+ return [z.to_dict() for z in sorted_zones[:5]]
110
+
111
+ def get_crowd_flow_data(self, venue: Venue) -> Dict:
112
+ """Get data formatted for heatmap visualization."""
113
+ heatmap_points = []
114
+ for zone in venue.zones:
115
+ if zone.lat and zone.lng:
116
+ heatmap_points.append({
117
+ "lat": zone.lat,
118
+ "lng": zone.lng,
119
+ "weight": zone.occupancy_rate,
120
+ "zone_id": zone.id,
121
+ "zone_name": zone.name,
122
+ "density_color": zone.density_level.color,
123
+ })
124
+
125
+ return {
126
+ "heatmap_points": heatmap_points,
127
+ "zones": [z.to_dict() for z in venue.zones],
128
+ "overall_occupancy": round(venue.overall_occupancy * 100, 1),
129
+ }
services/firebase_service.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Firebase Firestore & Auth service with mock fallback."""
2
+
3
+ import os
4
+ import json
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # In-memory store for mock mode
12
+ _mock_store = {}
13
+ _firebase_app = None
14
+ _firestore_client = None
15
+
16
+
17
+ def init_firebase(app):
18
+ """Initialize Firebase Admin SDK or fall back to mock mode."""
19
+ global _firebase_app, _firestore_client
20
+
21
+ if app.config.get("USE_MOCK_SERVICES"):
22
+ logger.info("๐Ÿ”ถ Firebase running in MOCK mode")
23
+ return
24
+
25
+ try:
26
+ import firebase_admin
27
+ from firebase_admin import credentials, firestore
28
+
29
+ cred_path = app.config.get("FIREBASE_CREDENTIALS_PATH", "")
30
+ if cred_path and os.path.exists(cred_path):
31
+ cred = credentials.Certificate(cred_path)
32
+ _firebase_app = firebase_admin.initialize_app(cred)
33
+ _firestore_client = firestore.client()
34
+ logger.info("โœ… Firebase initialized successfully")
35
+ else:
36
+ logger.warning("โš ๏ธ Firebase credentials not found, using mock mode")
37
+ app.config["USE_MOCK_SERVICES"] = True
38
+ except Exception as e:
39
+ logger.error(f"โŒ Firebase init failed: {e}, falling back to mock mode")
40
+ app.config["USE_MOCK_SERVICES"] = True
41
+
42
+
43
+ def _is_mock():
44
+ """Check if running in mock mode."""
45
+ return _firestore_client is None
46
+
47
+
48
+ # โ”€โ”€โ”€ Firestore CRUD Operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
+
50
+
51
+ def set_document(collection: str, doc_id: str, data: dict) -> bool:
52
+ """Create or overwrite a document."""
53
+ try:
54
+ if _is_mock():
55
+ if collection not in _mock_store:
56
+ _mock_store[collection] = {}
57
+ _mock_store[collection][doc_id] = {
58
+ **data,
59
+ "_updated_at": datetime.utcnow().isoformat(),
60
+ }
61
+ return True
62
+
63
+ _firestore_client.collection(collection).document(doc_id).set(data)
64
+ return True
65
+ except Exception as e:
66
+ logger.error(f"Firestore set_document error: {e}")
67
+ return False
68
+
69
+
70
+ def get_document(collection: str, doc_id: str) -> Optional[dict]:
71
+ """Retrieve a single document."""
72
+ try:
73
+ if _is_mock():
74
+ return _mock_store.get(collection, {}).get(doc_id)
75
+
76
+ doc = _firestore_client.collection(collection).document(doc_id).get()
77
+ return doc.to_dict() if doc.exists else None
78
+ except Exception as e:
79
+ logger.error(f"Firestore get_document error: {e}")
80
+ return None
81
+
82
+
83
+ def get_collection(collection: str) -> list:
84
+ """Retrieve all documents in a collection."""
85
+ try:
86
+ if _is_mock():
87
+ store = _mock_store.get(collection, {})
88
+ return [{"id": k, **v} for k, v in store.items()]
89
+
90
+ docs = _firestore_client.collection(collection).stream()
91
+ return [{"id": doc.id, **doc.to_dict()} for doc in docs]
92
+ except Exception as e:
93
+ logger.error(f"Firestore get_collection error: {e}")
94
+ return []
95
+
96
+
97
+ def update_document(collection: str, doc_id: str, data: dict) -> bool:
98
+ """Update fields in a document."""
99
+ try:
100
+ if _is_mock():
101
+ if collection not in _mock_store:
102
+ _mock_store[collection] = {}
103
+ if doc_id not in _mock_store[collection]:
104
+ _mock_store[collection][doc_id] = {}
105
+ _mock_store[collection][doc_id].update(data)
106
+ _mock_store[collection][doc_id]["_updated_at"] = (
107
+ datetime.utcnow().isoformat()
108
+ )
109
+ return True
110
+
111
+ _firestore_client.collection(collection).document(doc_id).update(data)
112
+ return True
113
+ except Exception as e:
114
+ logger.error(f"Firestore update_document error: {e}")
115
+ return False
116
+
117
+
118
+ def delete_document(collection: str, doc_id: str) -> bool:
119
+ """Delete a document."""
120
+ try:
121
+ if _is_mock():
122
+ if collection in _mock_store and doc_id in _mock_store[collection]:
123
+ del _mock_store[collection][doc_id]
124
+ return True
125
+
126
+ _firestore_client.collection(collection).document(doc_id).delete()
127
+ return True
128
+ except Exception as e:
129
+ logger.error(f"Firestore delete_document error: {e}")
130
+ return False
131
+
132
+
133
+ def query_documents(collection: str, field: str, op: str, value) -> list:
134
+ """Query documents with a filter."""
135
+ try:
136
+ if _is_mock():
137
+ results = []
138
+ for doc_id, doc_data in _mock_store.get(collection, {}).items():
139
+ doc_val = doc_data.get(field)
140
+ if op == "==" and doc_val == value:
141
+ results.append({"id": doc_id, **doc_data})
142
+ elif op == ">" and doc_val is not None and doc_val > value:
143
+ results.append({"id": doc_id, **doc_data})
144
+ elif op == "<" and doc_val is not None and doc_val < value:
145
+ results.append({"id": doc_id, **doc_data})
146
+ elif op == ">=" and doc_val is not None and doc_val >= value:
147
+ results.append({"id": doc_id, **doc_data})
148
+ elif op == "<=" and doc_val is not None and doc_val <= value:
149
+ results.append({"id": doc_id, **doc_data})
150
+ return results
151
+
152
+ docs = (
153
+ _firestore_client.collection(collection)
154
+ .where(field, op, value)
155
+ .stream()
156
+ )
157
+ return [{"id": doc.id, **doc.to_dict()} for doc in docs]
158
+ except Exception as e:
159
+ logger.error(f"Firestore query error: {e}")
160
+ return []
161
+
162
+
163
+ # โ”€โ”€โ”€ Auth Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
164
+
165
+ # Mock user store
166
+ _mock_users = {
167
+ "fan1": {
168
+ "uid": "fan1",
169
+ "email": "fan@venueflow.demo",
170
+ "display_name": "Demo Fan",
171
+ "role": "attendee",
172
+ "password": "demo123",
173
+ },
174
+ "op1": {
175
+ "uid": "op1",
176
+ "email": "operator@venueflow.demo",
177
+ "display_name": "Demo Operator",
178
+ "role": "operator",
179
+ "password": "demo123",
180
+ },
181
+ }
182
+
183
+
184
+ def verify_mock_login(email: str, password: str) -> Optional[dict]:
185
+ """Verify login credentials in mock mode."""
186
+ for uid, user in _mock_users.items():
187
+ if user["email"] == email and user["password"] == password:
188
+ return {
189
+ "uid": uid,
190
+ "email": user["email"],
191
+ "display_name": user["display_name"],
192
+ "role": user["role"],
193
+ }
194
+ return None
195
+
196
+
197
+ def create_mock_user(email: str, password: str, display_name: str, role: str = "attendee") -> Optional[dict]:
198
+ """Create a user in mock mode."""
199
+ uid = f"user_{len(_mock_users) + 1}"
200
+ _mock_users[uid] = {
201
+ "uid": uid,
202
+ "email": email,
203
+ "display_name": display_name,
204
+ "role": role,
205
+ "password": password,
206
+ }
207
+ return {"uid": uid, "email": email, "display_name": display_name, "role": role}
208
+
209
+
210
+ def get_mock_user(uid: str) -> Optional[dict]:
211
+ """Get a user by UID in mock mode."""
212
+ user = _mock_users.get(uid)
213
+ if user:
214
+ return {
215
+ "uid": uid,
216
+ "email": user["email"],
217
+ "display_name": user["display_name"],
218
+ "role": user["role"],
219
+ }
220
+ return None
services/gemini_service.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini AI chatbot service with mock fallback."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Optional
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _genai_model = None
9
+ _is_mock = True
10
+
11
+
12
+ def init_gemini(app):
13
+ """Initialize Google Gemini API or fall back to mock."""
14
+ global _genai_model, _is_mock
15
+
16
+ if app.config.get("USE_MOCK_SERVICES"):
17
+ logger.info("๐Ÿ”ถ Gemini running in MOCK mode")
18
+ return
19
+
20
+ api_key = app.config.get("GEMINI_API_KEY", "")
21
+ if not api_key:
22
+ logger.warning("โš ๏ธ No Gemini API key, using mock mode")
23
+ return
24
+
25
+ try:
26
+ import google.generativeai as genai
27
+
28
+ genai.configure(api_key=api_key)
29
+ _genai_model = genai.GenerativeModel("gemini-2.5-flash")
30
+ _is_mock = False
31
+ logger.info("โœ… Gemini AI initialized successfully")
32
+ except Exception as e:
33
+ logger.error(f"โŒ Gemini init failed: {e}, using mock mode")
34
+
35
+
36
+ SYSTEM_PROMPT = """You are VenueFlow Assistant, an AI concierge for a large sporting venue.
37
+ You help attendees with:
38
+ - Finding the shortest queues for food, merchandise, and restrooms
39
+ - Navigating to different areas of the venue
40
+ - Checking crowd density in different zones
41
+ - General event information and recommendations
42
+
43
+ Be concise, friendly, and helpful. Use emojis sparingly for clarity.
44
+ When providing directions, be specific about zone names and landmarks.
45
+ Always prioritize attendee safety and comfort."""
46
+
47
+
48
+ # Smart mock responses based on keywords
49
+ MOCK_RESPONSES = {
50
+ "queue": "Based on current data, the **North Food Court** (Gate 3 area) has the shortest wait at ~4 minutes ๐Ÿ•. The South Food Court is busier at ~12 minutes. Would you like me to add you to the virtual queue?",
51
+ "food": "Here are the least crowded food options right now:\n\n1. **Stall N3** - North Food Court (4 min wait)\n2. **Stall W1** - West Concourse (6 min wait)\n3. **Stall E2** - East Pavilion (8 min wait)\n\nWould you like directions to any of these?",
52
+ "restroom": "The nearest restroom with the shortest queue is at **West Concourse Level 1** (~2 min wait) ๐Ÿšป. The restrooms near Gate 5 are currently busy (~10 min wait). Want me to navigate you there?",
53
+ "crowd": "Current venue status:\n\n๐ŸŸข **Low density**: North Stand, West Pavilion\n๐ŸŸก **Moderate**: East Stand, South Concourse\n๐Ÿ”ด **High**: Main Pavilion, VIP Area\n\nI recommend the North Stand concourse for a more comfortable experience.",
54
+ "seat": "To find your seat, head through **Gate 3** and follow the signs for your section. Staff members at each entry point can help direct you. Would you like turn-by-turn directions?",
55
+ "exit": "The nearest exit from your area is **Gate 5 (West Exit)**. Current crowd density at this exit is ๐ŸŸข Low. For the fastest exit after the event, I recommend **Gate 7 (North Exit)** โ€” it typically has 30% less congestion.",
56
+ "help": "I can help you with:\n\n๐Ÿ• **Food & Drinks** โ€” Find shortest queues\n๐Ÿšป **Restrooms** โ€” Locate nearest available\n๐Ÿ—บ๏ธ **Navigation** โ€” Get directions anywhere\n๐Ÿ“Š **Crowd Info** โ€” Check zone density\n๐Ÿšช **Exits** โ€” Find fastest exit route\n\nWhat would you like to know?",
57
+ "navigate": "I can help you navigate! Which zone are you heading to? You can say things like:\n- 'Navigate to Gate 5'\n- 'Find nearest food stall'\n- 'How do I get to my seat in Section B?'",
58
+ "weather": "Current conditions: โ˜€๏ธ Clear skies, 28ยฐC. It's a great day for the match! Don't forget to stay hydrated โ€” the nearest water station is at the **North Concourse**.",
59
+ "emergency": "๐Ÿšจ **In case of emergency:**\n1. Stay calm and follow staff directions\n2. Move to the nearest marked exit\n3. Do not use elevators\n4. Emergency exits are marked with green signs\n5. First aid stations are at Gates 1, 4, and 7\n\nIf you need immediate help, call venue security or alert the nearest staff member.",
60
+ }
61
+
62
+ DEFAULT_MOCK_RESPONSE = "I'm your VenueFlow assistant! I can help with finding food, restrooms, navigating the venue, checking crowd levels, and more. What would you like to know? ๐Ÿ˜Š"
63
+
64
+
65
+ def get_chat_response(
66
+ message: str,
67
+ venue_context: Dict = None,
68
+ conversation_history: List[Dict] = None,
69
+ ) -> str:
70
+ """Get a response from the AI chatbot."""
71
+ if _is_mock:
72
+ return _get_mock_response(message)
73
+
74
+ try:
75
+ # Build context-aware prompt
76
+ context_parts = [SYSTEM_PROMPT]
77
+
78
+ if venue_context:
79
+ context_parts.append(f"\nCurrent venue data: {venue_context}")
80
+
81
+ if conversation_history:
82
+ for msg in conversation_history[-6:]: # Last 6 messages
83
+ role = "user" if msg.get("role") == "user" else "model"
84
+ context_parts.append(f"{role}: {msg.get('content', '')}")
85
+
86
+ full_prompt = "\n".join(context_parts) + f"\n\nuser: {message}\nassistant:"
87
+
88
+ response = _genai_model.generate_content(
89
+ full_prompt,
90
+ generation_config={
91
+ "temperature": 0.7,
92
+ "max_output_tokens": 500,
93
+ "top_p": 0.9,
94
+ },
95
+ safety_settings={
96
+ "HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE",
97
+ "HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
98
+ "HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_MEDIUM_AND_ABOVE",
99
+ "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
100
+ },
101
+ )
102
+
103
+ return response.text.strip()
104
+
105
+ except Exception as e:
106
+ logger.error(f"Gemini API error: {e}")
107
+ return "I'm having trouble connecting right now. Please try again in a moment, or ask a venue staff member for help."
108
+
109
+
110
+ def _get_mock_response(message: str) -> str:
111
+ """Generate a contextual mock response based on keywords."""
112
+ msg_lower = message.lower()
113
+
114
+ for keyword, response in MOCK_RESPONSES.items():
115
+ if keyword in msg_lower:
116
+ return response
117
+
118
+ return DEFAULT_MOCK_RESPONSE
119
+
120
+
121
+ def get_quick_suggestions() -> List[str]:
122
+ """Return suggested quick-reply prompts for the chatbot."""
123
+ return [
124
+ "Where's the shortest food queue?",
125
+ "Find nearest restroom",
126
+ "How crowded is it?",
127
+ "Navigate to my seat",
128
+ "Where's the nearest exit?",
129
+ "What's the weather like?",
130
+ ]
services/maps_service.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Maps and wayfinding service."""
2
+
3
+ import logging
4
+ import math
5
+ from typing import List, Dict, Optional, Tuple
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class MapsService:
11
+ """Venue navigation and wayfinding service."""
12
+
13
+ def __init__(self, venue=None):
14
+ self.venue = venue
15
+ # Pre-built adjacency for routing between zones
16
+ self._adjacency: Dict[str, List[str]] = {}
17
+ self._walking_speeds = {
18
+ "normal": 1.2, # m/s
19
+ "crowded": 0.6, # m/s in dense areas
20
+ "accessible": 0.8, # m/s for accessible routes
21
+ }
22
+
23
+ def set_venue(self, venue):
24
+ """Set the active venue and build routing graph."""
25
+ self.venue = venue
26
+ self._build_adjacency()
27
+
28
+ def _build_adjacency(self):
29
+ """Build zone adjacency graph based on proximity."""
30
+ if not self.venue:
31
+ return
32
+
33
+ self._adjacency = {}
34
+ zones = self.venue.zones
35
+
36
+ for z in zones:
37
+ self._adjacency[z.id] = []
38
+ for other in zones:
39
+ if z.id != other.id:
40
+ dist = self._haversine(z.lat, z.lng, other.lat, other.lng)
41
+ if dist < 300: # Within 300 meters = adjacent
42
+ self._adjacency[z.id].append(other.id)
43
+
44
+ def _haversine(self, lat1, lon1, lat2, lon2) -> float:
45
+ """Calculate distance between two points in meters."""
46
+ R = 6371000 # Earth's radius in meters
47
+ phi1 = math.radians(lat1)
48
+ phi2 = math.radians(lat2)
49
+ dphi = math.radians(lat2 - lat1)
50
+ dlambda = math.radians(lon2 - lon1)
51
+
52
+ a = (
53
+ math.sin(dphi / 2) ** 2
54
+ + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
55
+ )
56
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
57
+ return R * c
58
+
59
+ def find_route(
60
+ self,
61
+ from_zone_id: str,
62
+ to_zone_id: str,
63
+ accessible: bool = False,
64
+ ) -> Optional[Dict]:
65
+ """Find a route between two zones using BFS (shortest path)."""
66
+ if not self.venue:
67
+ return None
68
+
69
+ from_zone = self.venue.get_zone(from_zone_id)
70
+ to_zone = self.venue.get_zone(to_zone_id)
71
+ if not from_zone or not to_zone:
72
+ return None
73
+
74
+ # BFS for shortest path
75
+ visited = {from_zone_id}
76
+ queue = [(from_zone_id, [from_zone_id])]
77
+ path = None
78
+
79
+ while queue:
80
+ current, current_path = queue.pop(0)
81
+ if current == to_zone_id:
82
+ path = current_path
83
+ break
84
+
85
+ for neighbor in self._adjacency.get(current, []):
86
+ if neighbor not in visited:
87
+ # Skip non-accessible zones if accessible routing requested
88
+ nzone = self.venue.get_zone(neighbor)
89
+ if accessible and nzone and not nzone.is_accessible:
90
+ continue
91
+ visited.add(neighbor)
92
+ queue.append((neighbor, current_path + [neighbor]))
93
+
94
+ if not path:
95
+ # Fallback: direct route
96
+ path = [from_zone_id, to_zone_id]
97
+
98
+ # Build route details
99
+ steps = []
100
+ total_distance = 0
101
+ for i in range(len(path) - 1):
102
+ z1 = self.venue.get_zone(path[i])
103
+ z2 = self.venue.get_zone(path[i + 1])
104
+ if z1 and z2:
105
+ dist = self._haversine(z1.lat, z1.lng, z2.lat, z2.lng)
106
+ total_distance += dist
107
+ steps.append({
108
+ "from": z1.name,
109
+ "to": z2.name,
110
+ "distance_m": round(dist),
111
+ "instruction": f"Head towards {z2.name}",
112
+ "from_coords": {"lat": z1.lat, "lng": z1.lng},
113
+ "to_coords": {"lat": z2.lat, "lng": z2.lng},
114
+ })
115
+
116
+ speed = (
117
+ self._walking_speeds["accessible"]
118
+ if accessible
119
+ else self._walking_speeds["normal"]
120
+ )
121
+ est_time_sec = total_distance / speed if speed > 0 else 0
122
+
123
+ return {
124
+ "from": from_zone.name,
125
+ "to": to_zone.name,
126
+ "path": path,
127
+ "steps": steps,
128
+ "total_distance_m": round(total_distance),
129
+ "estimated_time_sec": round(est_time_sec),
130
+ "estimated_time_display": self._format_time(est_time_sec),
131
+ "accessible": accessible,
132
+ }
133
+
134
+ def find_nearest(
135
+ self,
136
+ from_zone_id: str,
137
+ target_type: str,
138
+ accessible: bool = False,
139
+ ) -> Optional[Dict]:
140
+ """Find the nearest zone of a specific type."""
141
+ if not self.venue:
142
+ return None
143
+
144
+ from_zone = self.venue.get_zone(from_zone_id)
145
+ if not from_zone:
146
+ return None
147
+
148
+ targets = self.venue.get_zones_by_type(target_type)
149
+ if accessible:
150
+ targets = [z for z in targets if z.is_accessible]
151
+
152
+ if not targets:
153
+ return None
154
+
155
+ # Sort by distance and choose nearest with lowest crowd
156
+ scored = []
157
+ for t in targets:
158
+ dist = self._haversine(from_zone.lat, from_zone.lng, t.lat, t.lng)
159
+ # Penalize crowded zones
160
+ crowd_penalty = t.occupancy_rate * 100
161
+ score = dist + crowd_penalty
162
+ scored.append((t, dist, score))
163
+
164
+ scored.sort(key=lambda x: x[2])
165
+ best = scored[0]
166
+
167
+ route = self.find_route(from_zone_id, best[0].id, accessible)
168
+
169
+ return {
170
+ "target_zone": best[0].to_dict(),
171
+ "distance_m": round(best[1]),
172
+ "route": route,
173
+ }
174
+
175
+ def get_points_of_interest(self) -> List[Dict]:
176
+ """Get all points of interest for the map."""
177
+ if not self.venue:
178
+ return []
179
+
180
+ pois = []
181
+ type_icons = {
182
+ "food_court": "๐Ÿ•",
183
+ "restroom": "๐Ÿšป",
184
+ "gate": "๐Ÿšช",
185
+ "stand": "๐ŸŸ๏ธ",
186
+ "concourse": "๐Ÿšถ",
187
+ "parking": "๐Ÿ…ฟ๏ธ",
188
+ "first_aid": "๐Ÿฅ",
189
+ "merchandise": "๐Ÿ›๏ธ",
190
+ }
191
+
192
+ for zone in self.venue.zones:
193
+ pois.append({
194
+ "id": zone.id,
195
+ "name": zone.name,
196
+ "type": zone.zone_type,
197
+ "icon": type_icons.get(zone.zone_type, "๐Ÿ“"),
198
+ "lat": zone.lat,
199
+ "lng": zone.lng,
200
+ "is_accessible": zone.is_accessible,
201
+ "density_level": zone.density_level.value,
202
+ "density_color": zone.density_level.color,
203
+ })
204
+
205
+ return pois
206
+
207
+ def _format_time(self, seconds: float) -> str:
208
+ """Format seconds into a readable time string."""
209
+ if seconds < 60:
210
+ return f"{int(seconds)} sec"
211
+ minutes = int(seconds / 60)
212
+ remaining_sec = int(seconds % 60)
213
+ if minutes < 60:
214
+ return f"{minutes} min" + (f" {remaining_sec}s" if remaining_sec > 0 else "")
215
+ hours = int(minutes / 60)
216
+ remaining_min = int(minutes % 60)
217
+ return f"{hours}h {remaining_min}m"
services/notification_service.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Notification and alert service."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import List, Dict, Optional
6
+ from enum import Enum
7
+ import uuid
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AlertSeverity(Enum):
13
+ INFO = "info"
14
+ WARNING = "warning"
15
+ CRITICAL = "critical"
16
+ EMERGENCY = "emergency"
17
+
18
+ @property
19
+ def color(self):
20
+ return {
21
+ "info": "#3b82f6",
22
+ "warning": "#f59e0b",
23
+ "critical": "#ef4444",
24
+ "emergency": "#dc2626",
25
+ }[self.value]
26
+
27
+ @property
28
+ def icon(self):
29
+ return {
30
+ "info": "โ„น๏ธ",
31
+ "warning": "โš ๏ธ",
32
+ "critical": "๐Ÿ”ด",
33
+ "emergency": "๐Ÿšจ",
34
+ }[self.value]
35
+
36
+
37
+ class NotificationService:
38
+ """Manages alerts and notifications for both attendees and operators."""
39
+
40
+ def __init__(self):
41
+ self._alerts: List[Dict] = []
42
+ self._subscribers: List[callable] = []
43
+ self._max_alerts = 500
44
+
45
+ def subscribe(self, callback):
46
+ """Register a callback for new alerts."""
47
+ self._subscribers.append(callback)
48
+
49
+ def create_alert(
50
+ self,
51
+ title: str,
52
+ message: str,
53
+ severity: str = "info",
54
+ zone_id: str = None,
55
+ target_role: str = "all",
56
+ ) -> Dict:
57
+ """Create and broadcast a new alert."""
58
+ alert = {
59
+ "id": str(uuid.uuid4())[:8],
60
+ "title": title,
61
+ "message": message,
62
+ "severity": severity,
63
+ "severity_color": AlertSeverity(severity).color,
64
+ "severity_icon": AlertSeverity(severity).icon,
65
+ "zone_id": zone_id,
66
+ "target_role": target_role,
67
+ "timestamp": datetime.utcnow().isoformat(),
68
+ "read": False,
69
+ }
70
+
71
+ self._alerts.insert(0, alert)
72
+
73
+ # Trim old alerts
74
+ if len(self._alerts) > self._max_alerts:
75
+ self._alerts = self._alerts[: self._max_alerts]
76
+
77
+ # Notify subscribers
78
+ for callback in self._subscribers:
79
+ try:
80
+ callback(alert)
81
+ except Exception as e:
82
+ logger.error(f"Notification callback error: {e}")
83
+
84
+ return alert
85
+
86
+ def get_alerts(
87
+ self,
88
+ role: str = "all",
89
+ severity: str = None,
90
+ zone_id: str = None,
91
+ limit: int = 50,
92
+ ) -> List[Dict]:
93
+ """Get filtered alerts."""
94
+ filtered = self._alerts
95
+
96
+ if role != "all":
97
+ filtered = [
98
+ a for a in filtered
99
+ if a["target_role"] in ("all", role)
100
+ ]
101
+
102
+ if severity:
103
+ filtered = [a for a in filtered if a["severity"] == severity]
104
+
105
+ if zone_id:
106
+ filtered = [
107
+ a for a in filtered
108
+ if a.get("zone_id") is None or a["zone_id"] == zone_id
109
+ ]
110
+
111
+ return filtered[:limit]
112
+
113
+ def mark_read(self, alert_id: str) -> bool:
114
+ """Mark an alert as read."""
115
+ for alert in self._alerts:
116
+ if alert["id"] == alert_id:
117
+ alert["read"] = True
118
+ return True
119
+ return False
120
+
121
+ def get_unread_count(self, role: str = "all") -> int:
122
+ """Count unread alerts for a role."""
123
+ alerts = self.get_alerts(role=role)
124
+ return sum(1 for a in alerts if not a["read"])
125
+
126
+ def clear_all(self):
127
+ """Clear all alerts."""
128
+ self._alerts = []
services/queue_service.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Queue management service with virtual queue support."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from typing import List, Dict, Optional
6
+ from models.queue import QueueStation, VirtualQueueTicket
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class QueueService:
12
+ """Manages queues, wait times, and virtual queue reservations."""
13
+
14
+ def __init__(self):
15
+ self._stations: Dict[str, QueueStation] = {}
16
+ self._virtual_tickets: Dict[str, VirtualQueueTicket] = {}
17
+ self._station_history: Dict[str, List[Dict]] = {}
18
+
19
+ def register_station(self, station: QueueStation):
20
+ """Register a queue station."""
21
+ self._stations[station.id] = station
22
+
23
+ def register_stations(self, stations: List[QueueStation]):
24
+ """Register multiple queue stations."""
25
+ for s in stations:
26
+ self._stations[s.id] = s
27
+
28
+ def get_station(self, station_id: str) -> Optional[QueueStation]:
29
+ """Get a station by ID."""
30
+ return self._stations.get(station_id)
31
+
32
+ def get_all_stations(self) -> List[Dict]:
33
+ """Get all stations as dicts."""
34
+ return [s.to_dict() for s in self._stations.values()]
35
+
36
+ def get_stations_by_category(self, category: str) -> List[Dict]:
37
+ """Get stations filtered by category."""
38
+ return [
39
+ s.to_dict()
40
+ for s in self._stations.values()
41
+ if s.category == category and s.is_open
42
+ ]
43
+
44
+ def get_stations_by_zone(self, zone_id: str) -> List[Dict]:
45
+ """Get stations in a specific zone."""
46
+ return [
47
+ s.to_dict()
48
+ for s in self._stations.values()
49
+ if s.zone_id == zone_id
50
+ ]
51
+
52
+ def update_queue_length(self, station_id: str, new_length: int) -> Optional[Dict]:
53
+ """Update the queue length for a station."""
54
+ station = self._stations.get(station_id)
55
+ if not station:
56
+ return None
57
+
58
+ station.current_length = max(0, new_length)
59
+
60
+ # Track history
61
+ if station_id not in self._station_history:
62
+ self._station_history[station_id] = []
63
+ self._station_history[station_id].append({
64
+ "length": station.current_length,
65
+ "wait_min": station.estimated_wait_minutes,
66
+ "timestamp": datetime.utcnow().isoformat(),
67
+ })
68
+
69
+ # Trim history
70
+ if len(self._station_history[station_id]) > 100:
71
+ self._station_history[station_id] = self._station_history[station_id][-50:]
72
+
73
+ return station.to_dict()
74
+
75
+ def get_shortest_queue(self, category: str = None) -> Optional[Dict]:
76
+ """Find the station with the shortest wait time."""
77
+ stations = [
78
+ s for s in self._stations.values()
79
+ if s.is_open and (category is None or s.category == category)
80
+ ]
81
+ if not stations:
82
+ return None
83
+ shortest = min(stations, key=lambda s: s.estimated_wait_minutes)
84
+ return shortest.to_dict()
85
+
86
+ def get_queue_summary(self) -> Dict:
87
+ """Get an overview of all queues."""
88
+ open_stations = [s for s in self._stations.values() if s.is_open]
89
+ categories = {}
90
+
91
+ for station in open_stations:
92
+ cat = station.category
93
+ if cat not in categories:
94
+ categories[cat] = {
95
+ "category": cat,
96
+ "total_stations": 0,
97
+ "total_in_queue": 0,
98
+ "avg_wait_minutes": 0.0,
99
+ "shortest_wait": float("inf"),
100
+ "shortest_station": None,
101
+ }
102
+ categories[cat]["total_stations"] += 1
103
+ categories[cat]["total_in_queue"] += station.current_length
104
+
105
+ wait = station.estimated_wait_minutes
106
+ if wait < categories[cat]["shortest_wait"]:
107
+ categories[cat]["shortest_wait"] = wait
108
+ categories[cat]["shortest_station"] = station.name
109
+
110
+ # Calculate averages
111
+ for cat_data in categories.values():
112
+ if cat_data["total_stations"] > 0:
113
+ cat_data["avg_wait_minutes"] = round(
114
+ sum(
115
+ s.estimated_wait_minutes
116
+ for s in open_stations
117
+ if s.category == cat_data["category"]
118
+ ) / cat_data["total_stations"],
119
+ 1,
120
+ )
121
+ if cat_data["shortest_wait"] == float("inf"):
122
+ cat_data["shortest_wait"] = 0
123
+
124
+ return {
125
+ "total_stations": len(open_stations),
126
+ "total_people_waiting": sum(s.current_length for s in open_stations),
127
+ "categories": categories,
128
+ "stations": [s.to_dict() for s in open_stations],
129
+ }
130
+
131
+ # โ”€โ”€โ”€ Virtual Queue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
+
133
+ def join_virtual_queue(self, user_id: str, station_id: str) -> Optional[Dict]:
134
+ """Create a virtual queue reservation."""
135
+ station = self._stations.get(station_id)
136
+ if not station or not station.is_open:
137
+ return None
138
+
139
+ # Check if user already has a ticket for this station
140
+ existing = [
141
+ t for t in self._virtual_tickets.values()
142
+ if t.user_id == user_id and t.station_id == station_id and t.status == "waiting"
143
+ ]
144
+ if existing:
145
+ return existing[0].to_dict()
146
+
147
+ # Count waiting tickets for this station
148
+ waiting_count = sum(
149
+ 1 for t in self._virtual_tickets.values()
150
+ if t.station_id == station_id and t.status == "waiting"
151
+ )
152
+
153
+ ticket = VirtualQueueTicket(
154
+ user_id=user_id,
155
+ station_id=station_id,
156
+ station_name=station.name,
157
+ category=station.category,
158
+ position=waiting_count + 1,
159
+ estimated_ready_time=datetime.utcnow() + timedelta(
160
+ seconds=station.avg_service_time_sec * (waiting_count + 1)
161
+ ),
162
+ )
163
+
164
+ self._virtual_tickets[ticket.id] = ticket
165
+ return ticket.to_dict()
166
+
167
+ def get_user_tickets(self, user_id: str) -> List[Dict]:
168
+ """Get all virtual tickets for a user."""
169
+ return [
170
+ t.to_dict()
171
+ for t in self._virtual_tickets.values()
172
+ if t.user_id == user_id
173
+ ]
174
+
175
+ def cancel_ticket(self, ticket_id: str, user_id: str) -> bool:
176
+ """Cancel a virtual queue ticket."""
177
+ ticket = self._virtual_tickets.get(ticket_id)
178
+ if ticket and ticket.user_id == user_id and ticket.status == "waiting":
179
+ ticket.status = "expired"
180
+ return True
181
+ return False
182
+
183
+ def get_station_history(self, station_id: str, last_n: int = 20) -> List[Dict]:
184
+ """Get queue length history for a station."""
185
+ return self._station_history.get(station_id, [])[-last_n:]
services/simulator.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Realistic event simulation engine for demo/testing."""
2
+
3
+ import random
4
+ import math
5
+ import logging
6
+ import threading
7
+ import time
8
+ from datetime import datetime
9
+ from typing import Dict, Optional, Callable
10
+ from models.venue import Venue, Zone
11
+ from models.queue import QueueStation
12
+ from models.event import Event, EventPhase
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def create_demo_venue() -> Venue:
18
+ """Create a realistic cricket stadium layout (based on a typical Indian cricket ground)."""
19
+ # Center coordinates (representative sports venue in Mumbai)
20
+ center_lat = 18.9388
21
+ center_lng = 72.8255
22
+
23
+ zones = [
24
+ # Stands
25
+ Zone(id="north_stand", name="North Stand", zone_type="stand", capacity=8000,
26
+ lat=center_lat + 0.0015, lng=center_lng, amenities=["seating", "shade"]),
27
+ Zone(id="south_stand", name="South Stand", zone_type="stand", capacity=8000,
28
+ lat=center_lat - 0.0015, lng=center_lng, amenities=["seating", "shade"]),
29
+ Zone(id="east_pavilion", name="East Pavilion", zone_type="stand", capacity=6000,
30
+ lat=center_lat, lng=center_lng + 0.0015, amenities=["seating", "premium"]),
31
+ Zone(id="west_pavilion", name="West Pavilion", zone_type="stand", capacity=6000,
32
+ lat=center_lat, lng=center_lng - 0.0015, amenities=["seating", "premium"]),
33
+ Zone(id="main_pavilion", name="Main Pavilion (VIP)", zone_type="stand", capacity=3000,
34
+ lat=center_lat + 0.001, lng=center_lng + 0.001, amenities=["seating", "vip", "lounge"]),
35
+
36
+ # Concourses
37
+ Zone(id="north_concourse", name="North Concourse", zone_type="concourse", capacity=4000,
38
+ lat=center_lat + 0.002, lng=center_lng, amenities=["walking"]),
39
+ Zone(id="south_concourse", name="South Concourse", zone_type="concourse", capacity=4000,
40
+ lat=center_lat - 0.002, lng=center_lng, amenities=["walking"]),
41
+ Zone(id="east_concourse", name="East Concourse", zone_type="concourse", capacity=3000,
42
+ lat=center_lat, lng=center_lng + 0.002, amenities=["walking"]),
43
+ Zone(id="west_concourse", name="West Concourse", zone_type="concourse", capacity=3000,
44
+ lat=center_lat, lng=center_lng - 0.002, amenities=["walking"]),
45
+
46
+ # Gates
47
+ Zone(id="gate_1", name="Gate 1 (Main)", zone_type="gate", capacity=2000,
48
+ lat=center_lat + 0.0025, lng=center_lng - 0.001, amenities=["entry", "security"]),
49
+ Zone(id="gate_3", name="Gate 3 (North)", zone_type="gate", capacity=1500,
50
+ lat=center_lat + 0.0025, lng=center_lng + 0.001, amenities=["entry", "security"]),
51
+ Zone(id="gate_5", name="Gate 5 (West)", zone_type="gate", capacity=1500,
52
+ lat=center_lat, lng=center_lng - 0.0025, amenities=["entry", "security"]),
53
+ Zone(id="gate_7", name="Gate 7 (South)", zone_type="gate", capacity=1500,
54
+ lat=center_lat - 0.0025, lng=center_lng, amenities=["entry", "security"]),
55
+
56
+ # Food Courts
57
+ Zone(id="food_north", name="North Food Court", zone_type="food_court", capacity=1500,
58
+ lat=center_lat + 0.0018, lng=center_lng + 0.0005, amenities=["food", "beverages"]),
59
+ Zone(id="food_south", name="South Food Court", zone_type="food_court", capacity=1500,
60
+ lat=center_lat - 0.0018, lng=center_lng - 0.0005, amenities=["food", "beverages"]),
61
+ Zone(id="food_west", name="West Food Stalls", zone_type="food_court", capacity=800,
62
+ lat=center_lat - 0.0005, lng=center_lng - 0.0018, amenities=["food"]),
63
+
64
+ # Restrooms
65
+ Zone(id="restroom_n1", name="Restrooms N1", zone_type="restroom", capacity=200,
66
+ lat=center_lat + 0.0017, lng=center_lng - 0.0008, amenities=["restroom", "accessible"]),
67
+ Zone(id="restroom_s1", name="Restrooms S1", zone_type="restroom", capacity=200,
68
+ lat=center_lat - 0.0017, lng=center_lng + 0.0008, amenities=["restroom", "accessible"]),
69
+ Zone(id="restroom_e1", name="Restrooms E1", zone_type="restroom", capacity=150,
70
+ lat=center_lat + 0.0005, lng=center_lng + 0.0017, amenities=["restroom"]),
71
+ Zone(id="restroom_w1", name="Restrooms W1", zone_type="restroom", capacity=150,
72
+ lat=center_lat - 0.0005, lng=center_lng - 0.0017, amenities=["restroom"]),
73
+
74
+ # Parking
75
+ Zone(id="parking_a", name="Parking Lot A", zone_type="parking", capacity=3000,
76
+ lat=center_lat + 0.003, lng=center_lng, amenities=["parking"]),
77
+ Zone(id="parking_b", name="Parking Lot B", zone_type="parking", capacity=2000,
78
+ lat=center_lat, lng=center_lng - 0.003, amenities=["parking"]),
79
+ ]
80
+
81
+ venue = Venue(
82
+ id="wankhede",
83
+ name="Wankhede Stadium",
84
+ city="Mumbai",
85
+ total_capacity=50000,
86
+ zones=zones,
87
+ center_lat=center_lat,
88
+ center_lng=center_lng,
89
+ map_zoom=16,
90
+ )
91
+
92
+ return venue
93
+
94
+
95
+ def create_demo_queue_stations() -> list:
96
+ """Create realistic queue stations for the demo venue."""
97
+ stations = [
98
+ # Food stalls
99
+ QueueStation(id="f_n1", name="Chaat Counter", category="food", zone_id="food_north",
100
+ avg_service_time_sec=60, lat=18.9406, lng=72.8260),
101
+ QueueStation(id="f_n2", name="Biryani House", category="food", zone_id="food_north",
102
+ avg_service_time_sec=90, lat=18.9406, lng=72.8258),
103
+ QueueStation(id="f_n3", name="Pizza & Burgers", category="food", zone_id="food_north",
104
+ avg_service_time_sec=75, lat=18.9406, lng=72.8262),
105
+ QueueStation(id="f_s1", name="South Indian Delights", category="food", zone_id="food_south",
106
+ avg_service_time_sec=70, lat=18.9370, lng=72.8250),
107
+ QueueStation(id="f_s2", name="Beverages & Ice Cream", category="food", zone_id="food_south",
108
+ avg_service_time_sec=45, lat=18.9370, lng=72.8252),
109
+ QueueStation(id="f_w1", name="Quick Bites", category="food", zone_id="food_west",
110
+ avg_service_time_sec=40, lat=18.9385, lng=72.8237),
111
+
112
+ # Merchandise
113
+ QueueStation(id="m_1", name="Team Jersey Shop", category="merch", zone_id="north_concourse",
114
+ avg_service_time_sec=120, lat=18.9408, lng=72.8255),
115
+ QueueStation(id="m_2", name="Souvenir Stand", category="merch", zone_id="east_concourse",
116
+ avg_service_time_sec=90, lat=18.9388, lng=72.8275),
117
+
118
+ # Restrooms (physical queues)
119
+ QueueStation(id="r_n1", name="Restrooms N1", category="restroom", zone_id="restroom_n1",
120
+ avg_service_time_sec=120, lat=18.9405, lng=72.8247),
121
+ QueueStation(id="r_s1", name="Restrooms S1", category="restroom", zone_id="restroom_s1",
122
+ avg_service_time_sec=120, lat=18.9371, lng=72.8263),
123
+ QueueStation(id="r_e1", name="Restrooms E1", category="restroom", zone_id="restroom_e1",
124
+ avg_service_time_sec=110, lat=18.9393, lng=72.8272),
125
+ QueueStation(id="r_w1", name="Restrooms W1", category="restroom", zone_id="restroom_w1",
126
+ avg_service_time_sec=110, lat=18.9383, lng=72.8238),
127
+
128
+ # Entry/Exit gates
129
+ QueueStation(id="g_1", name="Gate 1 Entry", category="entry", zone_id="gate_1",
130
+ avg_service_time_sec=15, lat=18.9413, lng=72.8245),
131
+ QueueStation(id="g_3", name="Gate 3 Entry", category="entry", zone_id="gate_3",
132
+ avg_service_time_sec=15, lat=18.9413, lng=72.8265),
133
+ QueueStation(id="g_5", name="Gate 5 Entry", category="entry", zone_id="gate_5",
134
+ avg_service_time_sec=15, lat=18.9388, lng=72.8230),
135
+ QueueStation(id="g_7", name="Gate 7 Entry", category="entry", zone_id="gate_7",
136
+ avg_service_time_sec=15, lat=18.9363, lng=72.8255),
137
+ ]
138
+
139
+ return stations
140
+
141
+
142
+ def create_demo_event() -> Event:
143
+ """Create a demo cricket match event."""
144
+ return Event(
145
+ id="ipl_2026_01",
146
+ name="IPL 2026 โ€” Mumbai Indians vs Chennai Super Kings",
147
+ sport="Cricket",
148
+ venue_id="wankhede",
149
+ date=datetime.now(),
150
+ home_team="Mumbai Indians",
151
+ away_team="Chennai Super Kings",
152
+ current_phase="first_half",
153
+ expected_attendance=33000,
154
+ actual_attendance=0,
155
+ weather="clear",
156
+ temperature_c=31.0,
157
+ )
158
+
159
+
160
+ # โ”€โ”€โ”€ Phase-based simulation profiles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
161
+
162
+ PHASE_PROFILES = {
163
+ "pre_event": {
164
+ "stand_fill": 0.0, "concourse_fill": 0.05, "gate_fill": 0.1,
165
+ "food_fill": 0.05, "restroom_fill": 0.05, "parking_fill": 0.3,
166
+ "queue_food": (0, 3), "queue_restroom": (0, 2), "queue_entry": (0, 5),
167
+ },
168
+ "gates_open": {
169
+ "stand_fill": 0.1, "concourse_fill": 0.3, "gate_fill": 0.7,
170
+ "food_fill": 0.15, "restroom_fill": 0.1, "parking_fill": 0.6,
171
+ "queue_food": (2, 8), "queue_restroom": (1, 4), "queue_entry": (10, 40),
172
+ },
173
+ "filling": {
174
+ "stand_fill": 0.4, "concourse_fill": 0.5, "gate_fill": 0.8,
175
+ "food_fill": 0.3, "restroom_fill": 0.2, "parking_fill": 0.8,
176
+ "queue_food": (5, 15), "queue_restroom": (3, 8), "queue_entry": (20, 60),
177
+ },
178
+ "first_half": {
179
+ "stand_fill": 0.8, "concourse_fill": 0.15, "gate_fill": 0.1,
180
+ "food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9,
181
+ "queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 5),
182
+ },
183
+ "halftime": {
184
+ "stand_fill": 0.5, "concourse_fill": 0.7, "gate_fill": 0.15,
185
+ "food_fill": 0.9, "restroom_fill": 0.8, "parking_fill": 0.9,
186
+ "queue_food": (15, 40), "queue_restroom": (10, 25), "queue_entry": (0, 3),
187
+ },
188
+ "second_half": {
189
+ "stand_fill": 0.75, "concourse_fill": 0.1, "gate_fill": 0.1,
190
+ "food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9,
191
+ "queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 2),
192
+ },
193
+ "event_end": {
194
+ "stand_fill": 0.3, "concourse_fill": 0.6, "gate_fill": 0.8,
195
+ "food_fill": 0.05, "restroom_fill": 0.3, "parking_fill": 0.9,
196
+ "queue_food": (0, 3), "queue_restroom": (3, 10), "queue_entry": (0, 0),
197
+ },
198
+ "exiting": {
199
+ "stand_fill": 0.1, "concourse_fill": 0.4, "gate_fill": 0.9,
200
+ "food_fill": 0.02, "restroom_fill": 0.2, "parking_fill": 0.7,
201
+ "queue_food": (0, 2), "queue_restroom": (1, 5), "queue_entry": (0, 0),
202
+ },
203
+ "post_event": {
204
+ "stand_fill": 0.0, "concourse_fill": 0.02, "gate_fill": 0.05,
205
+ "food_fill": 0.0, "restroom_fill": 0.02, "parking_fill": 0.2,
206
+ "queue_food": (0, 0), "queue_restroom": (0, 1), "queue_entry": (0, 0),
207
+ },
208
+ }
209
+
210
+ # Map zone types to profile keys
211
+ ZONE_TYPE_MAP = {
212
+ "stand": "stand_fill",
213
+ "concourse": "concourse_fill",
214
+ "gate": "gate_fill",
215
+ "food_court": "food_fill",
216
+ "restroom": "restroom_fill",
217
+ "parking": "parking_fill",
218
+ }
219
+
220
+
221
+ class Simulator:
222
+ """Simulates realistic crowd and queue dynamics."""
223
+
224
+ def __init__(self, venue: Venue, queue_service, crowd_service, notification_service, event: Event):
225
+ self.venue = venue
226
+ self.queue_service = queue_service
227
+ self.crowd_service = crowd_service
228
+ self.notification_service = notification_service
229
+ self.event = event
230
+ self._running = False
231
+ self._thread: Optional[threading.Thread] = None
232
+ self._speed = 1.0 # 1x = real-time, 2x = double speed, etc.
233
+ self._tick_count = 0
234
+ self._update_callback: Optional[Callable] = None
235
+
236
+ @property
237
+ def is_running(self) -> bool:
238
+ return self._running
239
+
240
+ def set_update_callback(self, callback: Callable):
241
+ """Set a callback to fire after each simulation tick."""
242
+ self._update_callback = callback
243
+
244
+ def set_phase(self, phase: str):
245
+ """Manually set the event phase."""
246
+ if phase in PHASE_PROFILES:
247
+ self.event.current_phase = phase
248
+ logger.info(f"๐ŸŽฌ Simulation phase changed to: {phase}")
249
+
250
+ def set_speed(self, speed: float):
251
+ """Set simulation speed (1.0 = real-time)."""
252
+ self._speed = max(0.1, min(10.0, speed))
253
+
254
+ def start(self):
255
+ """Start the simulation loop."""
256
+ if self._running:
257
+ return
258
+
259
+ self._running = True
260
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
261
+ self._thread.start()
262
+ logger.info("โ–ถ๏ธ Simulation started")
263
+
264
+ def stop(self):
265
+ """Stop the simulation loop."""
266
+ self._running = False
267
+ if self._thread:
268
+ self._thread.join(timeout=5)
269
+ logger.info("โน๏ธ Simulation stopped")
270
+
271
+ def tick(self):
272
+ """Execute one simulation tick โ€” update all zones and queues."""
273
+ phase = self.event.current_phase
274
+ profile = PHASE_PROFILES.get(phase, PHASE_PROFILES["pre_event"])
275
+
276
+ self._tick_count += 1
277
+
278
+ # Update zone crowd counts
279
+ for zone in self.venue.zones:
280
+ fill_key = ZONE_TYPE_MAP.get(zone.zone_type, "concourse_fill")
281
+ target_fill = profile.get(fill_key, 0.5)
282
+
283
+ # Add randomness (+/- 15%)
284
+ jitter = random.uniform(-0.15, 0.15)
285
+ target_count = int(zone.capacity * max(0, min(1, target_fill + jitter)))
286
+
287
+ # Smooth transition (don't jump instantly)
288
+ diff = target_count - zone.current_count
289
+ step = max(1, abs(diff) // 5)
290
+ if diff > 0:
291
+ new_count = zone.current_count + min(step, diff)
292
+ elif diff < 0:
293
+ new_count = zone.current_count - min(step, abs(diff))
294
+ else:
295
+ new_count = zone.current_count
296
+
297
+ self.crowd_service.update_zone_count(self.venue, zone.id, new_count)
298
+
299
+ # Update queue lengths
300
+ for station_id, station in self.queue_service._stations.items():
301
+ cat = station.category
302
+ queue_range = profile.get(f"queue_{cat}", (0, 5))
303
+ target = random.randint(queue_range[0], queue_range[1])
304
+
305
+ # Smooth transition
306
+ diff = target - station.current_length
307
+ step = max(1, abs(diff) // 3)
308
+ if diff > 0:
309
+ new_len = station.current_length + min(step, diff)
310
+ elif diff < 0:
311
+ new_len = station.current_length - min(step, abs(diff))
312
+ else:
313
+ new_len = station.current_length
314
+
315
+ self.queue_service.update_queue_length(station_id, max(0, new_len))
316
+
317
+ # Update attendance count
318
+ self.event.actual_attendance = self.venue.total_current
319
+
320
+ # Periodic alerts based on conditions
321
+ if self._tick_count % 10 == 0:
322
+ self._check_and_alert()
323
+
324
+ # Fire update callback
325
+ if self._update_callback:
326
+ try:
327
+ self._update_callback()
328
+ except Exception as e:
329
+ logger.error(f"Simulator callback error: {e}")
330
+
331
+ def _check_and_alert(self):
332
+ """Generate contextual alerts based on current conditions."""
333
+ for zone in self.venue.zones:
334
+ if zone.occupancy_rate > 0.9:
335
+ self.notification_service.create_alert(
336
+ title=f"High Density: {zone.name}",
337
+ message=f"{zone.name} is at {round(zone.occupancy_rate * 100)}% capacity. Consider redirecting foot traffic.",
338
+ severity="warning",
339
+ zone_id=zone.id,
340
+ target_role="operator",
341
+ )
342
+
343
+ def _run_loop(self):
344
+ """Main simulation loop running in a background thread."""
345
+ while self._running:
346
+ try:
347
+ self.tick()
348
+ time.sleep(2.0 / self._speed)
349
+ except Exception as e:
350
+ logger.error(f"Simulation error: {e}")
351
+ time.sleep(1)
352
+
353
+ def get_status(self) -> Dict:
354
+ """Get current simulation status."""
355
+ return {
356
+ "running": self._running,
357
+ "speed": self._speed,
358
+ "tick_count": self._tick_count,
359
+ "phase": self.event.current_phase,
360
+ "phase_label": self.event.phase.label,
361
+ "phase_description": self.event.phase.description,
362
+ "attendance": self.event.actual_attendance,
363
+ "venue_occupancy": round(self.venue.overall_occupancy * 100, 1),
364
+ }
services/translation_service.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google Cloud Translation service with mock fallback."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _translate_client = None
9
+ _is_mock = True
10
+
11
+ # Supported languages for the venue
12
+ SUPPORTED_LANGUAGES = {
13
+ "en": "English",
14
+ "hi": "เคนเคฟเคจเฅเคฆเฅ€",
15
+ "ta": "เฎคเฎฎเฎฟเฎดเฏ",
16
+ "te": "เฐคเฑ†เฐฒเฑเฐ—เฑ",
17
+ "kn": "เฒ•เฒจเณเฒจเฒก",
18
+ "mr": "เคฎเคฐเคพเค เฅ€",
19
+ "bn": "เฆฌเฆพเฆ‚เฆฒเฆพ",
20
+ "es": "Espaรฑol",
21
+ "fr": "Franรงais",
22
+ "de": "Deutsch",
23
+ "ja": "ๆ—ฅๆœฌ่ชž",
24
+ "zh": "ไธญๆ–‡",
25
+ }
26
+
27
+ # Cache for translations
28
+ _translation_cache = {}
29
+
30
+
31
+ def init_translation(app):
32
+ """Initialize Google Cloud Translation or fall back to mock."""
33
+ global _translate_client, _is_mock
34
+
35
+ if app.config.get("USE_MOCK_SERVICES"):
36
+ logger.info("๐Ÿ”ถ Translation running in MOCK mode")
37
+ return
38
+
39
+ api_key = app.config.get("GOOGLE_TRANSLATE_API_KEY", "")
40
+ if not api_key:
41
+ logger.warning("โš ๏ธ No Translation API key, using mock mode")
42
+ return
43
+
44
+ try:
45
+ from google.cloud import translate_v2 as translate
46
+
47
+ _translate_client = translate.Client()
48
+ _is_mock = False
49
+ logger.info("โœ… Google Translate initialized successfully")
50
+ except Exception as e:
51
+ logger.error(f"โŒ Translation init failed: {e}, using mock mode")
52
+
53
+
54
+ def translate_text(text: str, target_lang: str, source_lang: str = "en") -> str:
55
+ """Translate text to the target language."""
56
+ if target_lang == source_lang:
57
+ return text
58
+
59
+ # Check cache
60
+ cache_key = f"{source_lang}:{target_lang}:{text}"
61
+ if cache_key in _translation_cache:
62
+ return _translation_cache[cache_key]
63
+
64
+ if _is_mock:
65
+ # In mock mode, return original text with language indicator
66
+ result = f"[{SUPPORTED_LANGUAGES.get(target_lang, target_lang)}] {text}"
67
+ _translation_cache[cache_key] = result
68
+ return result
69
+
70
+ try:
71
+ result = _translate_client.translate(
72
+ text, target_language=target_lang, source_language=source_lang
73
+ )
74
+ translated = result["translatedText"]
75
+ _translation_cache[cache_key] = translated
76
+ return translated
77
+ except Exception as e:
78
+ logger.error(f"Translation error: {e}")
79
+ return text
80
+
81
+
82
+ def detect_language(text: str) -> str:
83
+ """Detect the language of input text."""
84
+ if _is_mock:
85
+ return "en"
86
+
87
+ try:
88
+ result = _translate_client.detect_language(text)
89
+ return result["language"]
90
+ except Exception as e:
91
+ logger.error(f"Language detection error: {e}")
92
+ return "en"
93
+
94
+
95
+ def get_supported_languages() -> dict:
96
+ """Return supported language options."""
97
+ return SUPPORTED_LANGUAGES
static/css/base.css ADDED
@@ -0,0 +1,1276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
2
+ VenueFlow Design System โ€” base.css
3
+ Premium dark-mode sporting venue interface
4
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
5
+
6
+ /* โ”€โ”€โ”€ Google Fonts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
7
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
8
+
9
+ /* โ”€โ”€โ”€ CSS Custom Properties (Design Tokens) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
10
+ :root {
11
+ /* Colors โ€” Deep stadium night palette */
12
+ --bg-primary: #0a0e1a;
13
+ --bg-secondary: #111827;
14
+ --bg-tertiary: #1a2236;
15
+ --bg-card: #1e2a3a;
16
+ --bg-card-hover: #243347;
17
+ --bg-glass: rgba(30, 42, 58, 0.7);
18
+ --bg-glass-strong: rgba(30, 42, 58, 0.9);
19
+ --bg-overlay: rgba(10, 14, 26, 0.85);
20
+
21
+ /* Accent colors */
22
+ --accent-primary: #6366f1; /* Indigo */
23
+ --accent-primary-light: #818cf8;
24
+ --accent-primary-dark: #4f46e5;
25
+ --accent-secondary: #06b6d4; /* Cyan */
26
+ --accent-secondary-light: #22d3ee;
27
+ --accent-success: #22c55e;
28
+ --accent-success-light: #4ade80;
29
+ --accent-warning: #f59e0b;
30
+ --accent-warning-light: #fbbf24;
31
+ --accent-danger: #ef4444;
32
+ --accent-danger-light: #f87171;
33
+ --accent-critical: #18181b;
34
+
35
+ /* Text */
36
+ --text-primary: #f1f5f9;
37
+ --text-secondary: #94a3b8;
38
+ --text-tertiary: #64748b;
39
+ --text-muted: #475569;
40
+ --text-inverse: #0a0e1a;
41
+
42
+ /* Density colors */
43
+ --density-low: #22c55e;
44
+ --density-moderate: #f59e0b;
45
+ --density-high: #ef4444;
46
+ --density-critical: #991b1b;
47
+
48
+ /* Borders */
49
+ --border-subtle: rgba(148, 163, 184, 0.1);
50
+ --border-medium: rgba(148, 163, 184, 0.2);
51
+ --border-strong: rgba(148, 163, 184, 0.3);
52
+ --border-accent: rgba(99, 102, 241, 0.4);
53
+
54
+ /* Gradients */
55
+ --gradient-primary: linear-gradient(135deg, #6366f1 0%, #06b6d4 100%);
56
+ --gradient-warm: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
57
+ --gradient-cool: linear-gradient(135deg, #06b6d4 0%, #6366f1 100%);
58
+ --gradient-dark: linear-gradient(180deg, #0a0e1a 0%, #111827 100%);
59
+ --gradient-card: linear-gradient(145deg, #1e2a3a 0%, #162030 100%);
60
+ --gradient-glass: linear-gradient(145deg, rgba(30,42,58,0.8) 0%, rgba(22,32,48,0.6) 100%);
61
+
62
+ /* Shadows */
63
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
64
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
65
+ --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
66
+ --shadow-xl: 0 16px 50px rgba(0, 0, 0, 0.6);
67
+ --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
68
+ --shadow-glow-cyan: 0 0 20px rgba(6, 182, 212, 0.3);
69
+
70
+ /* Typography */
71
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
72
+ --font-mono: 'JetBrains Mono', 'Consolas', monospace;
73
+ --font-size-xs: 0.75rem;
74
+ --font-size-sm: 0.875rem;
75
+ --font-size-base: 1rem;
76
+ --font-size-lg: 1.125rem;
77
+ --font-size-xl: 1.25rem;
78
+ --font-size-2xl: 1.5rem;
79
+ --font-size-3xl: 1.875rem;
80
+ --font-size-4xl: 2.25rem;
81
+
82
+ /* Spacing */
83
+ --space-1: 0.25rem;
84
+ --space-2: 0.5rem;
85
+ --space-3: 0.75rem;
86
+ --space-4: 1rem;
87
+ --space-5: 1.25rem;
88
+ --space-6: 1.5rem;
89
+ --space-8: 2rem;
90
+ --space-10: 2.5rem;
91
+ --space-12: 3rem;
92
+
93
+ /* Radius */
94
+ --radius-sm: 6px;
95
+ --radius-md: 10px;
96
+ --radius-lg: 14px;
97
+ --radius-xl: 20px;
98
+ --radius-full: 9999px;
99
+
100
+ /* Transitions */
101
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
102
+ --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
103
+ --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
104
+ --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
105
+
106
+ /* Z-index scale */
107
+ --z-base: 1;
108
+ --z-dropdown: 100;
109
+ --z-sticky: 200;
110
+ --z-modal: 500;
111
+ --z-toast: 700;
112
+ --z-chatbot: 800;
113
+ --z-overlay: 900;
114
+ }
115
+
116
+ /* โ”€โ”€โ”€ High Contrast Mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
117
+ [data-high-contrast="true"] {
118
+ --bg-primary: #000000;
119
+ --bg-secondary: #0a0a0a;
120
+ --bg-card: #1a1a1a;
121
+ --text-primary: #ffffff;
122
+ --text-secondary: #e0e0e0;
123
+ --border-subtle: rgba(255, 255, 255, 0.3);
124
+ --border-medium: rgba(255, 255, 255, 0.5);
125
+ }
126
+
127
+ /* โ”€โ”€โ”€ Reset & Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
128
+ *, *::before, *::after {
129
+ box-sizing: border-box;
130
+ margin: 0;
131
+ padding: 0;
132
+ }
133
+
134
+ html {
135
+ font-size: 16px;
136
+ scroll-behavior: smooth;
137
+ -webkit-text-size-adjust: 100%;
138
+ }
139
+
140
+ [data-large-text="true"] html,
141
+ [data-large-text="true"] {
142
+ font-size: 20px;
143
+ }
144
+
145
+ body {
146
+ font-family: var(--font-family);
147
+ background: var(--bg-primary);
148
+ color: var(--text-primary);
149
+ line-height: 1.6;
150
+ min-height: 100vh;
151
+ -webkit-font-smoothing: antialiased;
152
+ -moz-osx-font-smoothing: grayscale;
153
+ overflow-x: hidden;
154
+ }
155
+
156
+ /* โ”€โ”€โ”€ Skip to Content (A11y) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
157
+ .skip-link {
158
+ position: absolute;
159
+ top: -50px;
160
+ left: var(--space-4);
161
+ background: var(--accent-primary);
162
+ color: #fff;
163
+ padding: var(--space-3) var(--space-6);
164
+ border-radius: var(--radius-md);
165
+ z-index: 10000;
166
+ font-weight: 600;
167
+ text-decoration: none;
168
+ transition: top var(--transition-fast);
169
+ }
170
+
171
+ .skip-link:focus {
172
+ top: var(--space-4);
173
+ }
174
+
175
+ /* โ”€โ”€โ”€ Focus Indicators (A11y) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
176
+ :focus-visible {
177
+ outline: 3px solid var(--accent-primary-light);
178
+ outline-offset: 2px;
179
+ border-radius: var(--radius-sm);
180
+ }
181
+
182
+ /* โ”€โ”€โ”€ Typography โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
183
+ h1, h2, h3, h4, h5, h6 {
184
+ font-weight: 700;
185
+ line-height: 1.2;
186
+ color: var(--text-primary);
187
+ }
188
+
189
+ h1 { font-size: var(--font-size-4xl); letter-spacing: -0.02em; }
190
+ h2 { font-size: var(--font-size-3xl); letter-spacing: -0.01em; }
191
+ h3 { font-size: var(--font-size-2xl); }
192
+ h4 { font-size: var(--font-size-xl); }
193
+ h5 { font-size: var(--font-size-lg); }
194
+
195
+ p { color: var(--text-secondary); margin-bottom: var(--space-4); }
196
+
197
+ a {
198
+ color: var(--accent-primary-light);
199
+ text-decoration: none;
200
+ transition: color var(--transition-fast);
201
+ }
202
+
203
+ a:hover { color: var(--accent-secondary-light); }
204
+
205
+ /* โ”€โ”€โ”€ Scrollbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
206
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
207
+ ::-webkit-scrollbar-track { background: var(--bg-secondary); }
208
+ ::-webkit-scrollbar-thumb {
209
+ background: var(--bg-tertiary);
210
+ border-radius: var(--radius-full);
211
+ }
212
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
213
+
214
+ /* โ”€โ”€โ”€ Layout Containers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
215
+ .page-wrapper {
216
+ display: flex;
217
+ min-height: 100vh;
218
+ }
219
+
220
+ .main-content {
221
+ flex: 1;
222
+ padding: var(--space-6);
223
+ max-width: 1400px;
224
+ margin: 0 auto;
225
+ width: 100%;
226
+ }
227
+
228
+ .content-grid {
229
+ display: grid;
230
+ gap: var(--space-6);
231
+ }
232
+
233
+ .grid-2 { grid-template-columns: repeat(2, 1fr); }
234
+ .grid-3 { grid-template-columns: repeat(3, 1fr); }
235
+ .grid-4 { grid-template-columns: repeat(4, 1fr); }
236
+
237
+ @media (max-width: 1024px) {
238
+ .grid-4 { grid-template-columns: repeat(2, 1fr); }
239
+ .grid-3 { grid-template-columns: repeat(2, 1fr); }
240
+ }
241
+
242
+ @media (max-width: 640px) {
243
+ .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
244
+ .main-content { padding: var(--space-4); }
245
+ }
246
+
247
+ /* โ”€โ”€โ”€ Navigation Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
248
+ .navbar {
249
+ background: var(--bg-glass-strong);
250
+ backdrop-filter: blur(20px);
251
+ -webkit-backdrop-filter: blur(20px);
252
+ border-bottom: 1px solid var(--border-subtle);
253
+ padding: var(--space-3) var(--space-6);
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: space-between;
257
+ position: sticky;
258
+ top: 0;
259
+ z-index: var(--z-sticky);
260
+ }
261
+
262
+ .navbar-brand {
263
+ display: flex;
264
+ align-items: center;
265
+ gap: var(--space-3);
266
+ font-size: var(--font-size-xl);
267
+ font-weight: 800;
268
+ color: var(--text-primary);
269
+ text-decoration: none;
270
+ }
271
+
272
+ .navbar-brand .logo-icon {
273
+ font-size: 1.5rem;
274
+ background: var(--gradient-primary);
275
+ -webkit-background-clip: text;
276
+ -webkit-text-fill-color: transparent;
277
+ background-clip: text;
278
+ }
279
+
280
+ .navbar-nav {
281
+ display: flex;
282
+ align-items: center;
283
+ gap: var(--space-4);
284
+ list-style: none;
285
+ }
286
+
287
+ .navbar-nav a {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: var(--space-2);
291
+ color: var(--text-secondary);
292
+ font-weight: 500;
293
+ font-size: var(--font-size-sm);
294
+ padding: var(--space-2) var(--space-4);
295
+ border-radius: var(--radius-md);
296
+ transition: all var(--transition-base);
297
+ text-decoration: none;
298
+ min-height: 44px;
299
+ min-width: 44px;
300
+ justify-content: center;
301
+ }
302
+
303
+ .navbar-nav a:hover,
304
+ .navbar-nav a.active {
305
+ color: var(--text-primary);
306
+ background: var(--bg-card);
307
+ }
308
+
309
+ .navbar-nav a.active {
310
+ background: var(--accent-primary);
311
+ color: #fff;
312
+ }
313
+
314
+ .navbar-user {
315
+ display: flex;
316
+ align-items: center;
317
+ gap: var(--space-3);
318
+ }
319
+
320
+ .navbar-avatar {
321
+ width: 36px;
322
+ height: 36px;
323
+ border-radius: var(--radius-full);
324
+ background: var(--gradient-primary);
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ font-weight: 700;
329
+ font-size: var(--font-size-sm);
330
+ color: #fff;
331
+ }
332
+
333
+ /* โ”€โ”€โ”€ Mobile Nav โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
334
+ .mobile-nav {
335
+ display: none;
336
+ position: fixed;
337
+ bottom: 0;
338
+ left: 0;
339
+ right: 0;
340
+ background: var(--bg-glass-strong);
341
+ backdrop-filter: blur(20px);
342
+ border-top: 1px solid var(--border-subtle);
343
+ padding: var(--space-2) var(--space-4);
344
+ z-index: var(--z-sticky);
345
+ }
346
+
347
+ .mobile-nav-items {
348
+ display: flex;
349
+ justify-content: space-around;
350
+ list-style: none;
351
+ }
352
+
353
+ .mobile-nav-items a {
354
+ display: flex;
355
+ flex-direction: column;
356
+ align-items: center;
357
+ gap: 2px;
358
+ color: var(--text-tertiary);
359
+ font-size: var(--font-size-xs);
360
+ font-weight: 500;
361
+ padding: var(--space-2);
362
+ border-radius: var(--radius-md);
363
+ transition: all var(--transition-fast);
364
+ text-decoration: none;
365
+ min-height: 44px;
366
+ min-width: 44px;
367
+ justify-content: center;
368
+ }
369
+
370
+ .mobile-nav-items a .nav-icon {
371
+ font-size: 1.25rem;
372
+ }
373
+
374
+ .mobile-nav-items a:hover,
375
+ .mobile-nav-items a.active {
376
+ color: var(--accent-primary-light);
377
+ }
378
+
379
+ @media (max-width: 768px) {
380
+ .navbar-nav { display: none; }
381
+ .mobile-nav { display: block; }
382
+ .main-content { padding-bottom: 80px; }
383
+ }
384
+
385
+ /* โ”€โ”€โ”€ Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
386
+ .card {
387
+ background: var(--gradient-card);
388
+ border: 1px solid var(--border-subtle);
389
+ border-radius: var(--radius-lg);
390
+ padding: var(--space-6);
391
+ transition: all var(--transition-base);
392
+ position: relative;
393
+ overflow: hidden;
394
+ }
395
+
396
+ .card::before {
397
+ content: '';
398
+ position: absolute;
399
+ top: 0;
400
+ left: 0;
401
+ right: 0;
402
+ height: 2px;
403
+ background: var(--gradient-primary);
404
+ opacity: 0;
405
+ transition: opacity var(--transition-base);
406
+ }
407
+
408
+ .card:hover {
409
+ border-color: var(--border-medium);
410
+ box-shadow: var(--shadow-md);
411
+ transform: translateY(-1px);
412
+ }
413
+
414
+ .card:hover::before {
415
+ opacity: 1;
416
+ }
417
+
418
+ .card-header {
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: space-between;
422
+ margin-bottom: var(--space-4);
423
+ }
424
+
425
+ .card-title {
426
+ font-size: var(--font-size-lg);
427
+ font-weight: 600;
428
+ color: var(--text-primary);
429
+ }
430
+
431
+ .card-subtitle {
432
+ font-size: var(--font-size-sm);
433
+ color: var(--text-tertiary);
434
+ margin-top: var(--space-1);
435
+ }
436
+
437
+ .card-body {
438
+ color: var(--text-secondary);
439
+ }
440
+
441
+ /* โ”€โ”€โ”€ KPI Stat Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
442
+ .stat-card {
443
+ background: var(--gradient-card);
444
+ border: 1px solid var(--border-subtle);
445
+ border-radius: var(--radius-lg);
446
+ padding: var(--space-5);
447
+ display: flex;
448
+ flex-direction: column;
449
+ gap: var(--space-2);
450
+ position: relative;
451
+ overflow: hidden;
452
+ }
453
+
454
+ .stat-card .stat-icon {
455
+ font-size: 1.75rem;
456
+ margin-bottom: var(--space-1);
457
+ }
458
+
459
+ .stat-card .stat-value {
460
+ font-size: var(--font-size-3xl);
461
+ font-weight: 800;
462
+ line-height: 1;
463
+ background: var(--gradient-primary);
464
+ -webkit-background-clip: text;
465
+ -webkit-text-fill-color: transparent;
466
+ background-clip: text;
467
+ }
468
+
469
+ .stat-card .stat-value.warning {
470
+ background: var(--gradient-warm);
471
+ -webkit-background-clip: text;
472
+ background-clip: text;
473
+ }
474
+
475
+ .stat-card .stat-label {
476
+ font-size: var(--font-size-sm);
477
+ color: var(--text-tertiary);
478
+ font-weight: 500;
479
+ }
480
+
481
+ .stat-card .stat-change {
482
+ font-size: var(--font-size-xs);
483
+ font-weight: 600;
484
+ }
485
+
486
+ .stat-card .stat-change.positive { color: var(--accent-success); }
487
+ .stat-card .stat-change.negative { color: var(--accent-danger); }
488
+
489
+ /* โ”€โ”€โ”€ Buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
490
+ .btn {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ justify-content: center;
494
+ gap: var(--space-2);
495
+ padding: var(--space-3) var(--space-6);
496
+ font-family: var(--font-family);
497
+ font-size: var(--font-size-sm);
498
+ font-weight: 600;
499
+ border: 1px solid transparent;
500
+ border-radius: var(--radius-md);
501
+ cursor: pointer;
502
+ transition: all var(--transition-base);
503
+ text-decoration: none;
504
+ min-height: 44px;
505
+ min-width: 44px;
506
+ white-space: nowrap;
507
+ user-select: none;
508
+ }
509
+
510
+ .btn:active { transform: scale(0.97); }
511
+
512
+ .btn-primary {
513
+ background: var(--gradient-primary);
514
+ color: #fff;
515
+ border-color: transparent;
516
+ box-shadow: var(--shadow-sm), 0 0 0 0 rgba(99, 102, 241, 0);
517
+ }
518
+
519
+ .btn-primary:hover {
520
+ box-shadow: var(--shadow-md), var(--shadow-glow);
521
+ transform: translateY(-1px);
522
+ }
523
+
524
+ .btn-secondary {
525
+ background: var(--bg-card);
526
+ color: var(--text-secondary);
527
+ border-color: var(--border-medium);
528
+ }
529
+
530
+ .btn-secondary:hover {
531
+ background: var(--bg-card-hover);
532
+ color: var(--text-primary);
533
+ border-color: var(--border-strong);
534
+ }
535
+
536
+ .btn-success {
537
+ background: var(--accent-success);
538
+ color: #fff;
539
+ }
540
+
541
+ .btn-success:hover {
542
+ background: var(--accent-success-light);
543
+ box-shadow: 0 0 15px rgba(34, 197, 94, 0.3);
544
+ }
545
+
546
+ .btn-danger {
547
+ background: var(--accent-danger);
548
+ color: #fff;
549
+ }
550
+
551
+ .btn-danger:hover {
552
+ background: var(--accent-danger-light);
553
+ }
554
+
555
+ .btn-ghost {
556
+ background: transparent;
557
+ color: var(--text-secondary);
558
+ border-color: transparent;
559
+ }
560
+
561
+ .btn-ghost:hover {
562
+ background: var(--bg-card);
563
+ color: var(--text-primary);
564
+ }
565
+
566
+ .btn-sm {
567
+ padding: var(--space-2) var(--space-4);
568
+ font-size: var(--font-size-xs);
569
+ min-height: 36px;
570
+ }
571
+
572
+ .btn-lg {
573
+ padding: var(--space-4) var(--space-8);
574
+ font-size: var(--font-size-base);
575
+ }
576
+
577
+ .btn-block { width: 100%; }
578
+
579
+ .btn:disabled {
580
+ opacity: 0.5;
581
+ cursor: not-allowed;
582
+ transform: none !important;
583
+ }
584
+
585
+ /* โ”€โ”€โ”€ Form Inputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
586
+ .form-group {
587
+ display: flex;
588
+ flex-direction: column;
589
+ gap: var(--space-2);
590
+ margin-bottom: var(--space-5);
591
+ }
592
+
593
+ .form-label {
594
+ font-size: var(--font-size-sm);
595
+ font-weight: 600;
596
+ color: var(--text-secondary);
597
+ }
598
+
599
+ .form-input {
600
+ padding: var(--space-3) var(--space-4);
601
+ background: var(--bg-secondary);
602
+ border: 1px solid var(--border-medium);
603
+ border-radius: var(--radius-md);
604
+ color: var(--text-primary);
605
+ font-family: var(--font-family);
606
+ font-size: var(--font-size-base);
607
+ transition: all var(--transition-fast);
608
+ min-height: 44px;
609
+ }
610
+
611
+ .form-input:focus {
612
+ outline: none;
613
+ border-color: var(--accent-primary);
614
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
615
+ }
616
+
617
+ .form-input::placeholder {
618
+ color: var(--text-muted);
619
+ }
620
+
621
+ /* โ”€โ”€โ”€ Badges โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
622
+ .badge {
623
+ display: inline-flex;
624
+ align-items: center;
625
+ gap: var(--space-1);
626
+ padding: var(--space-1) var(--space-3);
627
+ font-size: var(--font-size-xs);
628
+ font-weight: 600;
629
+ border-radius: var(--radius-full);
630
+ white-space: nowrap;
631
+ }
632
+
633
+ .badge-low { background: rgba(34,197,94,0.15); color: var(--density-low); }
634
+ .badge-moderate { background: rgba(245,158,11,0.15); color: var(--density-moderate); }
635
+ .badge-high { background: rgba(239,68,68,0.15); color: var(--density-high); }
636
+ .badge-critical { background: rgba(153,27,27,0.2); color: #fca5a5; }
637
+ .badge-info { background: rgba(59,130,246,0.15); color: #60a5fa; }
638
+
639
+ /* โ”€โ”€โ”€ Progress Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
640
+ .progress {
641
+ width: 100%;
642
+ height: 8px;
643
+ background: var(--bg-secondary);
644
+ border-radius: var(--radius-full);
645
+ overflow: hidden;
646
+ position: relative;
647
+ }
648
+
649
+ .progress-bar {
650
+ height: 100%;
651
+ border-radius: var(--radius-full);
652
+ transition: width var(--transition-slow);
653
+ position: relative;
654
+ }
655
+
656
+ .progress-bar.low { background: var(--density-low); }
657
+ .progress-bar.moderate { background: var(--density-moderate); }
658
+ .progress-bar.high { background: var(--density-high); }
659
+ .progress-bar.critical { background: var(--density-critical); }
660
+
661
+ .progress-bar::after {
662
+ content: '';
663
+ position: absolute;
664
+ top: 0;
665
+ left: 0;
666
+ right: 0;
667
+ bottom: 0;
668
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
669
+ animation: shimmer 2s infinite;
670
+ }
671
+
672
+ @keyframes shimmer {
673
+ 0% { transform: translateX(-100%); }
674
+ 100% { transform: translateX(100%); }
675
+ }
676
+
677
+ /* โ”€โ”€โ”€ Toasts / Alerts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
678
+ .toast-container {
679
+ position: fixed;
680
+ top: var(--space-6);
681
+ right: var(--space-6);
682
+ z-index: var(--z-toast);
683
+ display: flex;
684
+ flex-direction: column;
685
+ gap: var(--space-3);
686
+ max-width: 400px;
687
+ }
688
+
689
+ .toast {
690
+ background: var(--bg-glass-strong);
691
+ backdrop-filter: blur(20px);
692
+ border: 1px solid var(--border-medium);
693
+ border-radius: var(--radius-lg);
694
+ padding: var(--space-4) var(--space-5);
695
+ display: flex;
696
+ align-items: flex-start;
697
+ gap: var(--space-3);
698
+ animation: slideInRight var(--transition-spring) forwards;
699
+ box-shadow: var(--shadow-lg);
700
+ }
701
+
702
+ .toast.info { border-left: 3px solid #3b82f6; }
703
+ .toast.success { border-left: 3px solid var(--accent-success); }
704
+ .toast.warning { border-left: 3px solid var(--accent-warning); }
705
+ .toast.error { border-left: 3px solid var(--accent-danger); }
706
+
707
+ @keyframes slideInRight {
708
+ from { transform: translateX(100%); opacity: 0; }
709
+ to { transform: translateX(0); opacity: 1; }
710
+ }
711
+
712
+ @keyframes slideOut {
713
+ from { transform: translateX(0); opacity: 1; }
714
+ to { transform: translateX(100%); opacity: 0; }
715
+ }
716
+
717
+ .toast-icon { font-size: 1.25rem; flex-shrink: 0; }
718
+ .toast-body { flex: 1; }
719
+ .toast-title { font-weight: 600; font-size: var(--font-size-sm); color: var(--text-primary); }
720
+ .toast-message { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
721
+
722
+ .toast-close {
723
+ background: none;
724
+ border: none;
725
+ color: var(--text-tertiary);
726
+ cursor: pointer;
727
+ font-size: 1.25rem;
728
+ padding: var(--space-1);
729
+ min-height: 44px;
730
+ min-width: 44px;
731
+ display: flex;
732
+ align-items: center;
733
+ justify-content: center;
734
+ }
735
+
736
+ /* โ”€โ”€โ”€ Density Indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
737
+ .density-dot {
738
+ width: 12px;
739
+ height: 12px;
740
+ border-radius: var(--radius-full);
741
+ display: inline-block;
742
+ flex-shrink: 0;
743
+ }
744
+
745
+ .density-dot.low { background: var(--density-low); box-shadow: 0 0 8px rgba(34,197,94,0.5); }
746
+ .density-dot.moderate { background: var(--density-moderate); box-shadow: 0 0 8px rgba(245,158,11,0.5); }
747
+ .density-dot.high { background: var(--density-high); box-shadow: 0 0 8px rgba(239,68,68,0.5); animation: pulse-dot 2s infinite; }
748
+ .density-dot.critical { background: var(--density-critical); box-shadow: 0 0 8px rgba(153,27,27,0.5); animation: pulse-dot 1s infinite; }
749
+
750
+ @keyframes pulse-dot {
751
+ 0%, 100% { transform: scale(1); opacity: 1; }
752
+ 50% { transform: scale(1.3); opacity: 0.7; }
753
+ }
754
+
755
+ /* โ”€โ”€โ”€ Loading Spinner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
756
+ .spinner {
757
+ width: 24px;
758
+ height: 24px;
759
+ border: 3px solid var(--border-medium);
760
+ border-top-color: var(--accent-primary);
761
+ border-radius: var(--radius-full);
762
+ animation: spin 0.8s linear infinite;
763
+ }
764
+
765
+ @keyframes spin {
766
+ to { transform: rotate(360deg); }
767
+ }
768
+
769
+ .loading-overlay {
770
+ position: absolute;
771
+ inset: 0;
772
+ display: flex;
773
+ align-items: center;
774
+ justify-content: center;
775
+ background: var(--bg-overlay);
776
+ border-radius: inherit;
777
+ z-index: var(--z-base);
778
+ }
779
+
780
+ /* โ”€โ”€โ”€ Chatbot Widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
781
+ .chatbot-toggle {
782
+ position: fixed;
783
+ bottom: 90px;
784
+ right: var(--space-6);
785
+ width: 56px;
786
+ height: 56px;
787
+ border-radius: var(--radius-full);
788
+ background: var(--gradient-primary);
789
+ color: #fff;
790
+ border: none;
791
+ font-size: 1.5rem;
792
+ cursor: pointer;
793
+ box-shadow: var(--shadow-lg), var(--shadow-glow);
794
+ z-index: var(--z-chatbot);
795
+ transition: all var(--transition-base);
796
+ display: flex;
797
+ align-items: center;
798
+ justify-content: center;
799
+ }
800
+
801
+ .chatbot-toggle:hover {
802
+ transform: scale(1.1);
803
+ box-shadow: var(--shadow-xl), 0 0 30px rgba(99, 102, 241, 0.5);
804
+ }
805
+
806
+ .chatbot-window {
807
+ position: fixed;
808
+ bottom: 160px;
809
+ right: var(--space-6);
810
+ width: 380px;
811
+ max-height: 500px;
812
+ background: var(--bg-secondary);
813
+ border: 1px solid var(--border-medium);
814
+ border-radius: var(--radius-xl);
815
+ box-shadow: var(--shadow-xl);
816
+ z-index: var(--z-chatbot);
817
+ display: flex;
818
+ flex-direction: column;
819
+ overflow: hidden;
820
+ opacity: 0;
821
+ transform: translateY(20px) scale(0.95);
822
+ pointer-events: none;
823
+ transition: all var(--transition-spring);
824
+ }
825
+
826
+ .chatbot-window.open {
827
+ opacity: 1;
828
+ transform: translateY(0) scale(1);
829
+ pointer-events: all;
830
+ }
831
+
832
+ .chatbot-header {
833
+ background: var(--gradient-primary);
834
+ padding: var(--space-4) var(--space-5);
835
+ display: flex;
836
+ align-items: center;
837
+ justify-content: space-between;
838
+ color: #fff;
839
+ }
840
+
841
+ .chatbot-header h4 { font-size: var(--font-size-base); color: #fff; }
842
+
843
+ .chatbot-messages {
844
+ flex: 1;
845
+ overflow-y: auto;
846
+ padding: var(--space-4);
847
+ display: flex;
848
+ flex-direction: column;
849
+ gap: var(--space-3);
850
+ min-height: 250px;
851
+ }
852
+
853
+ .chat-message {
854
+ max-width: 85%;
855
+ padding: var(--space-3) var(--space-4);
856
+ border-radius: var(--radius-lg);
857
+ font-size: var(--font-size-sm);
858
+ line-height: 1.5;
859
+ animation: fadeInUp 0.3s ease;
860
+ }
861
+
862
+ @keyframes fadeInUp {
863
+ from { transform: translateY(10px); opacity: 0; }
864
+ to { transform: translateY(0); opacity: 1; }
865
+ }
866
+
867
+ .chat-message.user {
868
+ align-self: flex-end;
869
+ background: var(--accent-primary);
870
+ color: #fff;
871
+ border-bottom-right-radius: var(--radius-sm);
872
+ }
873
+
874
+ .chat-message.bot {
875
+ align-self: flex-start;
876
+ background: var(--bg-card);
877
+ color: var(--text-primary);
878
+ border-bottom-left-radius: var(--radius-sm);
879
+ }
880
+
881
+ .chat-suggestions {
882
+ display: flex;
883
+ flex-wrap: wrap;
884
+ gap: var(--space-2);
885
+ padding: var(--space-2) var(--space-4);
886
+ }
887
+
888
+ .chat-suggestion {
889
+ font-size: var(--font-size-xs);
890
+ padding: var(--space-2) var(--space-3);
891
+ background: var(--bg-card);
892
+ border: 1px solid var(--border-subtle);
893
+ border-radius: var(--radius-full);
894
+ cursor: pointer;
895
+ color: var(--text-secondary);
896
+ transition: all var(--transition-fast);
897
+ white-space: nowrap;
898
+ }
899
+
900
+ .chat-suggestion:hover {
901
+ background: var(--accent-primary);
902
+ color: #fff;
903
+ border-color: var(--accent-primary);
904
+ }
905
+
906
+ .chatbot-input {
907
+ display: flex;
908
+ gap: var(--space-2);
909
+ padding: var(--space-3) var(--space-4);
910
+ border-top: 1px solid var(--border-subtle);
911
+ }
912
+
913
+ .chatbot-input input {
914
+ flex: 1;
915
+ padding: var(--space-3);
916
+ background: var(--bg-card);
917
+ border: 1px solid var(--border-subtle);
918
+ border-radius: var(--radius-md);
919
+ color: var(--text-primary);
920
+ font-family: var(--font-family);
921
+ font-size: var(--font-size-sm);
922
+ min-height: 44px;
923
+ }
924
+
925
+ .chatbot-input input:focus {
926
+ outline: none;
927
+ border-color: var(--accent-primary);
928
+ }
929
+
930
+ .chatbot-input button {
931
+ width: 44px;
932
+ height: 44px;
933
+ border-radius: var(--radius-md);
934
+ background: var(--accent-primary);
935
+ color: #fff;
936
+ border: none;
937
+ font-size: 1.1rem;
938
+ cursor: pointer;
939
+ transition: all var(--transition-fast);
940
+ display: flex;
941
+ align-items: center;
942
+ justify-content: center;
943
+ }
944
+
945
+ .chatbot-input button:hover {
946
+ background: var(--accent-primary-dark);
947
+ }
948
+
949
+ /* โ”€โ”€โ”€ Typing indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
950
+ .typing-indicator {
951
+ display: flex;
952
+ gap: 4px;
953
+ padding: var(--space-3) var(--space-4);
954
+ align-items: center;
955
+ }
956
+
957
+ .typing-indicator span {
958
+ width: 8px;
959
+ height: 8px;
960
+ background: var(--text-tertiary);
961
+ border-radius: var(--radius-full);
962
+ animation: typingBounce 1.4s ease infinite;
963
+ }
964
+
965
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
966
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
967
+
968
+ @keyframes typingBounce {
969
+ 0%, 60%, 100% { transform: translateY(0); }
970
+ 30% { transform: translateY(-6px); }
971
+ }
972
+
973
+ /* โ”€โ”€โ”€ Map Container โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
974
+ .map-container {
975
+ width: 100%;
976
+ height: 500px;
977
+ border-radius: var(--radius-lg);
978
+ overflow: hidden;
979
+ border: 1px solid var(--border-subtle);
980
+ }
981
+
982
+ @media (max-width: 640px) {
983
+ .map-container { height: 350px; }
984
+ }
985
+
986
+ /* โ”€โ”€โ”€ Queue Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
987
+ .queue-card {
988
+ background: var(--gradient-card);
989
+ border: 1px solid var(--border-subtle);
990
+ border-radius: var(--radius-lg);
991
+ padding: var(--space-5);
992
+ display: flex;
993
+ align-items: center;
994
+ gap: var(--space-4);
995
+ transition: all var(--transition-base);
996
+ }
997
+
998
+ .queue-card:hover {
999
+ border-color: var(--border-medium);
1000
+ box-shadow: var(--shadow-md);
1001
+ }
1002
+
1003
+ .queue-icon {
1004
+ font-size: 2rem;
1005
+ width: 48px;
1006
+ height: 48px;
1007
+ display: flex;
1008
+ align-items: center;
1009
+ justify-content: center;
1010
+ background: var(--bg-secondary);
1011
+ border-radius: var(--radius-md);
1012
+ }
1013
+
1014
+ .queue-details { flex: 1; }
1015
+ .queue-name { font-weight: 600; font-size: var(--font-size-base); color: var(--text-primary); }
1016
+ .queue-meta { font-size: var(--font-size-sm); color: var(--text-tertiary); }
1017
+ .queue-wait {
1018
+ text-align: right;
1019
+ font-weight: 700;
1020
+ font-size: var(--font-size-xl);
1021
+ }
1022
+
1023
+ /* โ”€โ”€โ”€ Phase Indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1024
+ .phase-badge {
1025
+ display: inline-flex;
1026
+ align-items: center;
1027
+ gap: var(--space-2);
1028
+ padding: var(--space-2) var(--space-4);
1029
+ background: var(--bg-card);
1030
+ border: 1px solid var(--border-accent);
1031
+ border-radius: var(--radius-full);
1032
+ font-size: var(--font-size-sm);
1033
+ font-weight: 600;
1034
+ color: var(--accent-primary-light);
1035
+ }
1036
+
1037
+ .phase-badge .phase-dot {
1038
+ width: 8px;
1039
+ height: 8px;
1040
+ border-radius: var(--radius-full);
1041
+ background: var(--accent-primary);
1042
+ animation: pulse-dot 2s infinite;
1043
+ }
1044
+
1045
+ /* โ”€โ”€โ”€ Auth Pages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1046
+ .auth-page {
1047
+ min-height: 100vh;
1048
+ display: flex;
1049
+ align-items: center;
1050
+ justify-content: center;
1051
+ background: var(--gradient-dark);
1052
+ padding: var(--space-6);
1053
+ position: relative;
1054
+ overflow: hidden;
1055
+ }
1056
+
1057
+ .auth-page::before {
1058
+ content: '';
1059
+ position: absolute;
1060
+ top: -50%;
1061
+ left: -50%;
1062
+ width: 200%;
1063
+ height: 200%;
1064
+ background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,0.08) 0%, transparent 50%),
1065
+ radial-gradient(ellipse at 70% 80%, rgba(6,182,212,0.06) 0%, transparent 50%);
1066
+ animation: auroraShift 20s ease infinite alternate;
1067
+ }
1068
+
1069
+ @keyframes auroraShift {
1070
+ 0% { transform: translate(0, 0) rotate(0deg); }
1071
+ 100% { transform: translate(-5%, 5%) rotate(3deg); }
1072
+ }
1073
+
1074
+ .auth-card {
1075
+ background: var(--bg-glass);
1076
+ backdrop-filter: blur(30px);
1077
+ border: 1px solid var(--border-subtle);
1078
+ border-radius: var(--radius-xl);
1079
+ padding: var(--space-10);
1080
+ width: 100%;
1081
+ max-width: 440px;
1082
+ position: relative;
1083
+ z-index: 1;
1084
+ box-shadow: var(--shadow-xl);
1085
+ }
1086
+
1087
+ .auth-card h1 {
1088
+ font-size: var(--font-size-2xl);
1089
+ text-align: center;
1090
+ margin-bottom: var(--space-2);
1091
+ }
1092
+
1093
+ .auth-card .auth-subtitle {
1094
+ text-align: center;
1095
+ color: var(--text-tertiary);
1096
+ margin-bottom: var(--space-8);
1097
+ font-size: var(--font-size-sm);
1098
+ }
1099
+
1100
+ .auth-footer {
1101
+ text-align: center;
1102
+ margin-top: var(--space-6);
1103
+ font-size: var(--font-size-sm);
1104
+ color: var(--text-tertiary);
1105
+ }
1106
+
1107
+ /* โ”€โ”€โ”€ Flash Messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1108
+ .flash-messages {
1109
+ margin-bottom: var(--space-4);
1110
+ }
1111
+
1112
+ .flash-msg {
1113
+ padding: var(--space-3) var(--space-4);
1114
+ border-radius: var(--radius-md);
1115
+ font-size: var(--font-size-sm);
1116
+ font-weight: 500;
1117
+ margin-bottom: var(--space-2);
1118
+ }
1119
+
1120
+ .flash-msg.success { background: rgba(34,197,94,0.15); color: var(--accent-success-light); border: 1px solid rgba(34,197,94,0.2); }
1121
+ .flash-msg.error { background: rgba(239,68,68,0.15); color: var(--accent-danger-light); border: 1px solid rgba(239,68,68,0.2); }
1122
+ .flash-msg.warning { background: rgba(245,158,11,0.15); color: var(--accent-warning-light); border: 1px solid rgba(245,158,11,0.2); }
1123
+ .flash-msg.info { background: rgba(59,130,246,0.15); color: #93c5fd; border: 1px solid rgba(59,130,246,0.2); }
1124
+
1125
+ /* โ”€โ”€โ”€ Hero Section (Attendee Home) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1126
+ .hero-banner {
1127
+ background: var(--gradient-primary);
1128
+ border-radius: var(--radius-xl);
1129
+ padding: var(--space-8) var(--space-10);
1130
+ margin-bottom: var(--space-6);
1131
+ position: relative;
1132
+ overflow: hidden;
1133
+ }
1134
+
1135
+ .hero-banner::after {
1136
+ content: '';
1137
+ position: absolute;
1138
+ top: -50%;
1139
+ right: -20%;
1140
+ width: 300px;
1141
+ height: 300px;
1142
+ background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
1143
+ border-radius: 50%;
1144
+ }
1145
+
1146
+ .hero-banner h2 { color: #fff; font-size: var(--font-size-2xl); margin-bottom: var(--space-2); }
1147
+ .hero-banner p { color: rgba(255,255,255,0.8); margin-bottom: 0; }
1148
+
1149
+ .hero-event-info {
1150
+ display: flex;
1151
+ gap: var(--space-6);
1152
+ margin-top: var(--space-4);
1153
+ flex-wrap: wrap;
1154
+ }
1155
+
1156
+ .hero-event-info .event-detail {
1157
+ display: flex;
1158
+ align-items: center;
1159
+ gap: var(--space-2);
1160
+ color: rgba(255,255,255,0.9);
1161
+ font-size: var(--font-size-sm);
1162
+ font-weight: 500;
1163
+ }
1164
+
1165
+ /* โ”€โ”€โ”€ Zone List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1166
+ .zone-list {
1167
+ display: flex;
1168
+ flex-direction: column;
1169
+ gap: var(--space-3);
1170
+ }
1171
+
1172
+ .zone-item {
1173
+ display: flex;
1174
+ align-items: center;
1175
+ gap: var(--space-4);
1176
+ padding: var(--space-3) var(--space-4);
1177
+ background: var(--bg-card);
1178
+ border-radius: var(--radius-md);
1179
+ border: 1px solid var(--border-subtle);
1180
+ transition: all var(--transition-fast);
1181
+ }
1182
+
1183
+ .zone-item:hover {
1184
+ border-color: var(--border-medium);
1185
+ background: var(--bg-card-hover);
1186
+ }
1187
+
1188
+ .zone-info { flex: 1; }
1189
+ .zone-name { font-weight: 600; font-size: var(--font-size-sm); }
1190
+ .zone-type { font-size: var(--font-size-xs); color: var(--text-tertiary); text-transform: capitalize; }
1191
+ .zone-occupancy { text-align: right; font-size: var(--font-size-sm); font-weight: 600; }
1192
+
1193
+ /* โ”€โ”€โ”€ Table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1194
+ .data-table {
1195
+ width: 100%;
1196
+ border-collapse: separate;
1197
+ border-spacing: 0;
1198
+ }
1199
+
1200
+ .data-table th {
1201
+ text-align: left;
1202
+ padding: var(--space-3) var(--space-4);
1203
+ font-size: var(--font-size-xs);
1204
+ font-weight: 600;
1205
+ color: var(--text-tertiary);
1206
+ text-transform: uppercase;
1207
+ letter-spacing: 0.05em;
1208
+ border-bottom: 1px solid var(--border-subtle);
1209
+ }
1210
+
1211
+ .data-table td {
1212
+ padding: var(--space-3) var(--space-4);
1213
+ font-size: var(--font-size-sm);
1214
+ border-bottom: 1px solid var(--border-subtle);
1215
+ }
1216
+
1217
+ .data-table tr:hover td {
1218
+ background: var(--bg-card);
1219
+ }
1220
+
1221
+ /* โ”€โ”€โ”€ Reduced Motion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1222
+ @media (prefers-reduced-motion: reduce) {
1223
+ *, *::before, *::after {
1224
+ animation-duration: 0.01ms !important;
1225
+ animation-iteration-count: 1 !important;
1226
+ transition-duration: 0.01ms !important;
1227
+ }
1228
+ }
1229
+
1230
+ [data-reduced-motion="true"] *,
1231
+ [data-reduced-motion="true"] *::before,
1232
+ [data-reduced-motion="true"] *::after {
1233
+ animation-duration: 0.01ms !important;
1234
+ animation-iteration-count: 1 !important;
1235
+ transition-duration: 0.01ms !important;
1236
+ }
1237
+
1238
+ /* โ”€โ”€โ”€ Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
1239
+ .text-center { text-align: center; }
1240
+ .text-right { text-align: right; }
1241
+ .mt-4 { margin-top: var(--space-4); }
1242
+ .mt-6 { margin-top: var(--space-6); }
1243
+ .mb-4 { margin-bottom: var(--space-4); }
1244
+ .mb-6 { margin-bottom: var(--space-6); }
1245
+ .flex { display: flex; }
1246
+ .flex-col { flex-direction: column; }
1247
+ .items-center { align-items: center; }
1248
+ .justify-between { justify-content: space-between; }
1249
+ .gap-2 { gap: var(--space-2); }
1250
+ .gap-3 { gap: var(--space-3); }
1251
+ .gap-4 { gap: var(--space-4); }
1252
+ .gap-6 { gap: var(--space-6); }
1253
+ .sr-only {
1254
+ position: absolute;
1255
+ width: 1px;
1256
+ height: 1px;
1257
+ padding: 0;
1258
+ margin: -1px;
1259
+ overflow: hidden;
1260
+ clip: rect(0,0,0,0);
1261
+ white-space: nowrap;
1262
+ border: 0;
1263
+ }
1264
+ .visually-hidden { composes: sr-only; }
1265
+
1266
+ @media (max-width: 768px) {
1267
+ .chatbot-window {
1268
+ right: var(--space-4);
1269
+ left: var(--space-4);
1270
+ width: auto;
1271
+ bottom: 100px;
1272
+ }
1273
+ .chatbot-toggle {
1274
+ bottom: 76px;
1275
+ }
1276
+ }
static/js/accessibility.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Accessibility Preferences
3
+ */
4
+
5
+ const A11Y_STORAGE_KEY = 'venueflow_a11y';
6
+
7
+ function getA11yPrefs() {
8
+ try {
9
+ return JSON.parse(localStorage.getItem(A11Y_STORAGE_KEY) || '{}');
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+
15
+ function saveA11yPrefs(prefs) {
16
+ localStorage.setItem(A11Y_STORAGE_KEY, JSON.stringify(prefs));
17
+ }
18
+
19
+ function toggleA11y(key) {
20
+ const prefs = getA11yPrefs();
21
+ prefs[key] = !prefs[key];
22
+ saveA11yPrefs(prefs);
23
+ applyA11yPrefs(prefs);
24
+ updateSwitchUI();
25
+ }
26
+
27
+ function applyA11yPrefs(prefs) {
28
+ const root = document.documentElement;
29
+
30
+ if (prefs.highContrast) {
31
+ root.dataset.highContrast = 'true';
32
+ } else {
33
+ root.dataset.highContrast = 'false';
34
+ }
35
+
36
+ if (prefs.largeText) {
37
+ root.dataset.largeText = 'true';
38
+ } else {
39
+ root.dataset.largeText = 'false';
40
+ }
41
+
42
+ if (prefs.reducedMotion) {
43
+ root.dataset.reducedMotion = 'true';
44
+ } else {
45
+ root.dataset.reducedMotion = 'false';
46
+ }
47
+ }
48
+
49
+ function updateSwitchUI() {
50
+ const prefs = getA11yPrefs();
51
+
52
+ const pairs = {
53
+ highContrast: { toggle: 'toggle-contrast', switch: 'switch-contrast' },
54
+ largeText: { toggle: 'toggle-text', switch: 'switch-text' },
55
+ reducedMotion:{ toggle: 'toggle-motion', switch: 'switch-motion' },
56
+ };
57
+
58
+ for (const [key, ids] of Object.entries(pairs)) {
59
+ const toggleEl = document.getElementById(ids.toggle);
60
+ const switchEl = document.getElementById(ids.switch);
61
+
62
+ if (toggleEl) {
63
+ toggleEl.setAttribute('aria-checked', prefs[key] ? 'true' : 'false');
64
+ }
65
+ if (switchEl) {
66
+ switchEl.textContent = prefs[key] ? 'ON' : 'OFF';
67
+ switchEl.style.color = prefs[key] ? 'var(--accent-success)' : 'var(--text-muted)';
68
+ switchEl.style.fontWeight = prefs[key] ? '700' : '500';
69
+ }
70
+ }
71
+ }
72
+
73
+ function setLanguage(lang) {
74
+ localStorage.setItem('venueflow_lang', lang);
75
+ showToast('Language Updated', `Preferred language set. Translation will apply to new content.`, 'info');
76
+ }
77
+
78
+ // Initialize on load
79
+ document.addEventListener('DOMContentLoaded', () => {
80
+ const prefs = getA11yPrefs();
81
+ applyA11yPrefs(prefs);
82
+ updateSwitchUI();
83
+ });
84
+
85
+ console.log('โ™ฟ Accessibility loaded');
static/js/app.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Core JavaScript Utilities
3
+ */
4
+
5
+ // โ”€โ”€โ”€ Toast Notification System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
6
+
7
+ function showToast(title, message, type = 'info', duration = 5000) {
8
+ const container = document.getElementById('toast-container');
9
+ if (!container) return;
10
+
11
+ const icons = { info: 'โ„น๏ธ', success: 'โœ…', warning: 'โš ๏ธ', error: 'โŒ' };
12
+
13
+ const toast = document.createElement('div');
14
+ toast.className = `toast ${type}`;
15
+ toast.setAttribute('role', 'alert');
16
+ toast.innerHTML = `
17
+ <span class="toast-icon">${icons[type] || 'โ„น๏ธ'}</span>
18
+ <div class="toast-body">
19
+ <div class="toast-title">${escapeHtml(title)}</div>
20
+ <div class="toast-message">${escapeHtml(message)}</div>
21
+ </div>
22
+ <button class="toast-close" onclick="this.parentElement.remove()" aria-label="Dismiss notification">&times;</button>
23
+ `;
24
+
25
+ container.appendChild(toast);
26
+
27
+ // Auto remove
28
+ setTimeout(() => {
29
+ if (toast.parentElement) {
30
+ toast.style.animation = 'slideOut 0.3s ease forwards';
31
+ setTimeout(() => toast.remove(), 300);
32
+ }
33
+ }, duration);
34
+ }
35
+
36
+
37
+ // โ”€โ”€โ”€ XSS Prevention โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
38
+
39
+ function escapeHtml(str) {
40
+ if (!str) return '';
41
+ const div = document.createElement('div');
42
+ div.textContent = str;
43
+ return div.innerHTML;
44
+ }
45
+
46
+
47
+ // โ”€โ”€โ”€ API Helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48
+
49
+ async function apiCall(url, options = {}) {
50
+ try {
51
+ const defaults = {
52
+ headers: { 'Content-Type': 'application/json' },
53
+ };
54
+ const resp = await fetch(url, { ...defaults, ...options });
55
+ const data = await resp.json();
56
+
57
+ if (!resp.ok) {
58
+ throw new Error(data.error || `HTTP ${resp.status}`);
59
+ }
60
+
61
+ return data;
62
+ } catch (err) {
63
+ console.error(`API Error [${url}]:`, err);
64
+ throw err;
65
+ }
66
+ }
67
+
68
+
69
+ // โ”€โ”€โ”€ Format Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
70
+
71
+ function formatNumber(num) {
72
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
73
+ return num.toString();
74
+ }
75
+
76
+ function formatWaitTime(minutes) {
77
+ if (minutes < 1) return '< 1 min';
78
+ if (minutes < 60) return Math.round(minutes) + ' min';
79
+ const h = Math.floor(minutes / 60);
80
+ const m = Math.round(minutes % 60);
81
+ return `${h}h ${m}m`;
82
+ }
83
+
84
+ function getDensityClass(level) {
85
+ return level || 'low';
86
+ }
87
+
88
+
89
+ // โ”€โ”€โ”€ Keyboard Accessibility Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
90
+
91
+ document.addEventListener('keydown', (e) => {
92
+ // Allow Enter and Space to activate buttons/links for a11y
93
+ if (e.key === 'Enter' || e.key === ' ') {
94
+ const el = document.activeElement;
95
+ if (el && (el.getAttribute('role') === 'button' || el.getAttribute('role') === 'switch')) {
96
+ e.preventDefault();
97
+ el.click();
98
+ }
99
+ }
100
+ });
101
+
102
+
103
+ // โ”€โ”€โ”€ Auto-dismiss flash messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
104
+
105
+ document.addEventListener('DOMContentLoaded', () => {
106
+ const flashMsgs = document.querySelectorAll('.flash-msg');
107
+ flashMsgs.forEach((msg, i) => {
108
+ setTimeout(() => {
109
+ msg.style.transition = 'opacity 0.5s';
110
+ msg.style.opacity = '0';
111
+ setTimeout(() => msg.remove(), 500);
112
+ }, 4000 + (i * 1000));
113
+ });
114
+ });
115
+
116
+ console.log('๐ŸŸ๏ธ VenueFlow app.js loaded');
static/js/chatbot.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” AI Chatbot Widget
3
+ */
4
+
5
+ let chatHistory = [];
6
+ let chatbotOpen = false;
7
+
8
+ function toggleChatbot() {
9
+ const window = document.getElementById('chatbot-window');
10
+ const toggle = document.getElementById('chatbot-toggle');
11
+
12
+ chatbotOpen = !chatbotOpen;
13
+
14
+ if (chatbotOpen) {
15
+ window.classList.add('open');
16
+ window.setAttribute('aria-hidden', 'false');
17
+ toggle.setAttribute('aria-expanded', 'true');
18
+ toggle.innerHTML = 'โœ•';
19
+ document.getElementById('chat-input').focus();
20
+ loadSuggestions();
21
+ } else {
22
+ window.classList.remove('open');
23
+ window.setAttribute('aria-hidden', 'true');
24
+ toggle.setAttribute('aria-expanded', 'false');
25
+ toggle.innerHTML = '๐Ÿ’ฌ';
26
+ }
27
+ }
28
+
29
+ async function loadSuggestions() {
30
+ try {
31
+ const suggestions = await apiCall('/api/chat/suggestions');
32
+ const container = document.getElementById('chat-suggestions');
33
+ if (container && suggestions) {
34
+ container.innerHTML = suggestions.map(s =>
35
+ `<button class="chat-suggestion" onclick="sendSuggestion('${escapeHtml(s)}')" aria-label="Quick reply: ${escapeHtml(s)}">${escapeHtml(s)}</button>`
36
+ ).join('');
37
+ }
38
+ } catch (e) {
39
+ console.error('Failed to load suggestions:', e);
40
+ }
41
+ }
42
+
43
+ function sendSuggestion(text) {
44
+ document.getElementById('chat-input').value = text;
45
+ sendChatMessage();
46
+ }
47
+
48
+ async function sendChatMessage() {
49
+ const input = document.getElementById('chat-input');
50
+ const message = input.value.trim();
51
+ if (!message) return;
52
+
53
+ // Add user message
54
+ addChatMessage(message, 'user');
55
+ chatHistory.push({ role: 'user', content: message });
56
+ input.value = '';
57
+
58
+ // Show typing indicator
59
+ showTypingIndicator();
60
+
61
+ // Hide suggestions after first message
62
+ const sugContainer = document.getElementById('chat-suggestions');
63
+ if (sugContainer) sugContainer.style.display = 'none';
64
+
65
+ try {
66
+ const data = await apiCall('/api/chat', {
67
+ method: 'POST',
68
+ body: JSON.stringify({
69
+ message: message,
70
+ history: chatHistory.slice(-6),
71
+ }),
72
+ });
73
+
74
+ removeTypingIndicator();
75
+ addChatMessage(data.response, 'bot');
76
+ chatHistory.push({ role: 'assistant', content: data.response });
77
+ } catch (err) {
78
+ removeTypingIndicator();
79
+ addChatMessage('Sorry, I had trouble processing that. Please try again.', 'bot');
80
+ }
81
+ }
82
+
83
+ function addChatMessage(text, role) {
84
+ const container = document.getElementById('chat-messages');
85
+ const msg = document.createElement('div');
86
+ msg.className = `chat-message ${role}`;
87
+
88
+ // Support basic markdown-like formatting
89
+ let formatted = escapeHtml(text);
90
+ // Bold
91
+ formatted = formatted.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
92
+ // Line breaks
93
+ formatted = formatted.replace(/\n/g, '<br>');
94
+
95
+ msg.innerHTML = formatted;
96
+ container.appendChild(msg);
97
+ container.scrollTop = container.scrollHeight;
98
+ }
99
+
100
+ function showTypingIndicator() {
101
+ const container = document.getElementById('chat-messages');
102
+ const indicator = document.createElement('div');
103
+ indicator.id = 'typing-indicator';
104
+ indicator.className = 'chat-message bot';
105
+ indicator.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
106
+ container.appendChild(indicator);
107
+ container.scrollTop = container.scrollHeight;
108
+ }
109
+
110
+ function removeTypingIndicator() {
111
+ const indicator = document.getElementById('typing-indicator');
112
+ if (indicator) indicator.remove();
113
+ }
114
+
115
+ // Enter key to send
116
+ document.addEventListener('DOMContentLoaded', () => {
117
+ const input = document.getElementById('chat-input');
118
+ if (input) {
119
+ input.addEventListener('keydown', (e) => {
120
+ if (e.key === 'Enter' && !e.shiftKey) {
121
+ e.preventDefault();
122
+ sendChatMessage();
123
+ }
124
+ });
125
+ }
126
+
127
+ const toggle = document.getElementById('chatbot-toggle');
128
+ if (toggle) {
129
+ toggle.addEventListener('click', toggleChatbot);
130
+ }
131
+ });
132
+
133
+ console.log('๐Ÿค– Chatbot loaded');
static/js/dashboard.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Operator Dashboard Logic
3
+ */
4
+
5
+ // Auto-refresh dashboard data
6
+ let dashboardRefreshInterval = null;
7
+
8
+ document.addEventListener('DOMContentLoaded', () => {
9
+ dashboardRefreshInterval = setInterval(refreshDashboard, 4000);
10
+ });
11
+
12
+ async function refreshDashboard() {
13
+ try {
14
+ const [crowdData, queueData] = await Promise.all([
15
+ apiCall('/api/crowd/summary'),
16
+ apiCall('/api/queue/summary'),
17
+ ]);
18
+
19
+ // Update KPIs
20
+ updateElement('kpi-attendance', crowdData.total_current || 0);
21
+ updateElement('kpi-occupancy', (crowdData.overall_occupancy || 0) + '%');
22
+ updateElement('kpi-waiting', queueData.total_people_waiting || 0);
23
+
24
+ const highZones = (crowdData.density_counts?.high || 0) + (crowdData.density_counts?.critical || 0);
25
+ updateElement('kpi-alerts', highZones);
26
+
27
+ // Update zone list
28
+ const zoneList = document.getElementById('op-zone-list');
29
+ if (zoneList && crowdData.zones) {
30
+ zoneList.innerHTML = crowdData.zones
31
+ .sort((a, b) => b.occupancy_rate - a.occupancy_rate)
32
+ .map(zone => `
33
+ <div class="zone-item" data-zone-id="${zone.id}">
34
+ <span class="density-dot ${zone.density_level}"></span>
35
+ <div class="zone-info">
36
+ <div class="zone-name">${escapeHtml(zone.name)}</div>
37
+ <div class="zone-type">${zone.zone_type.replace('_', ' ')} โ€ข ${zone.current_count}/${zone.capacity}</div>
38
+ </div>
39
+ <div class="zone-occupancy" style="color:${zone.density_color}">${zone.occupancy_rate}%</div>
40
+ </div>
41
+ `).join('');
42
+ }
43
+
44
+ // Update queue list
45
+ const queueList = document.getElementById('op-queue-list');
46
+ if (queueList && queueData.stations) {
47
+ queueList.innerHTML = queueData.stations
48
+ .sort((a, b) => b.estimated_wait_minutes - a.estimated_wait_minutes)
49
+ .map(station => `
50
+ <div class="zone-item">
51
+ <span style="font-size:1.25rem;">${station.category_icon}</span>
52
+ <div class="zone-info">
53
+ <div class="zone-name">${escapeHtml(station.name)}</div>
54
+ <div class="zone-type">${station.current_length} in line</div>
55
+ </div>
56
+ <div class="zone-occupancy" style="color:${station.wait_color}">${Math.round(station.estimated_wait_minutes)} min</div>
57
+ </div>
58
+ `).join('');
59
+ }
60
+ } catch (e) {
61
+ // Silent fail for dashboard refresh
62
+ }
63
+ }
64
+
65
+ async function startSim() {
66
+ try {
67
+ await apiCall('/api/simulator/start', { method: 'POST' });
68
+ showToast('Simulation Started', 'Crowd data is now being simulated in real-time', 'success');
69
+ setTimeout(() => location.reload(), 1000);
70
+ } catch (err) {
71
+ showToast('Error', err.message || 'Could not start simulation', 'error');
72
+ }
73
+ }
74
+
75
+ async function stopSim() {
76
+ try {
77
+ await apiCall('/api/simulator/stop', { method: 'POST' });
78
+ showToast('Simulation Stopped', 'Data is now frozen', 'info');
79
+ setTimeout(() => location.reload(), 1000);
80
+ } catch (err) {
81
+ showToast('Error', err.message || 'Could not stop simulation', 'error');
82
+ }
83
+ }
84
+
85
+ function updateElement(id, value) {
86
+ const el = document.getElementById(id);
87
+ if (el) el.textContent = value;
88
+ }
89
+
90
+ console.log('๐Ÿ“Š Dashboard loaded');
static/js/heatmap.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Canvas-based Crowd Heatmap Visualization
3
+ */
4
+
5
+ let heatmapCanvas, heatmapCtx;
6
+ let heatmapData = [];
7
+ let heatmapAnimFrame = null;
8
+
9
+ // Stadium zone approximate positions (normalized 0-1 for canvas)
10
+ const ZONE_POSITIONS = {
11
+ north_stand: { x: 0.5, y: 0.15, w: 0.35, h: 0.08, label: 'North Stand' },
12
+ south_stand: { x: 0.5, y: 0.85, w: 0.35, h: 0.08, label: 'South Stand' },
13
+ east_pavilion: { x: 0.85, y: 0.5, w: 0.08, h: 0.3, label: 'East Pavilion' },
14
+ west_pavilion: { x: 0.15, y: 0.5, w: 0.08, h: 0.3, label: 'West Pavilion' },
15
+ main_pavilion: { x: 0.72, y: 0.25, w: 0.12, h: 0.1, label: 'VIP' },
16
+ north_concourse: { x: 0.5, y: 0.06, w: 0.4, h: 0.04, label: 'N Concourse' },
17
+ south_concourse: { x: 0.5, y: 0.94, w: 0.4, h: 0.04, label: 'S Concourse' },
18
+ east_concourse: { x: 0.95, y: 0.5, w: 0.04, h: 0.35, label: 'E Concourse' },
19
+ west_concourse: { x: 0.05, y: 0.5, w: 0.04, h: 0.35, label: 'W Concourse' },
20
+ gate_1: { x: 0.35, y: 0.02, w: 0.06, h: 0.03, label: 'Gate 1' },
21
+ gate_3: { x: 0.65, y: 0.02, w: 0.06, h: 0.03, label: 'Gate 3' },
22
+ gate_5: { x: 0.02, y: 0.5, w: 0.03, h: 0.06, label: 'Gate 5' },
23
+ gate_7: { x: 0.5, y: 0.98, w: 0.06, h: 0.03, label: 'Gate 7' },
24
+ food_north: { x: 0.6, y: 0.1, w: 0.06, h: 0.04, label: '๐Ÿ• Food N' },
25
+ food_south: { x: 0.4, y: 0.9, w: 0.06, h: 0.04, label: '๐Ÿ• Food S' },
26
+ food_west: { x: 0.1, y: 0.6, w: 0.04, h: 0.06, label: '๐Ÿ• Food W' },
27
+ restroom_n1: { x: 0.38, y: 0.1, w: 0.04, h: 0.03, label: '๐Ÿšป N1' },
28
+ restroom_s1: { x: 0.62, y: 0.9, w: 0.04, h: 0.03, label: '๐Ÿšป S1' },
29
+ restroom_e1: { x: 0.9, y: 0.35, w: 0.03, h: 0.04, label: '๐Ÿšป E1' },
30
+ restroom_w1: { x: 0.1, y: 0.65, w: 0.03, h: 0.04, label: '๐Ÿšป W1' },
31
+ parking_a: { x: 0.5, y: -0.02, w: 0.15, h: 0.03, label: 'Parking A' },
32
+ parking_b: { x: -0.02, y: 0.3, w: 0.03, h: 0.1, label: 'Parking B' },
33
+ };
34
+
35
+ const DENSITY_COLORS = {
36
+ low: '#22c55e',
37
+ moderate: '#f59e0b',
38
+ high: '#ef4444',
39
+ critical: '#991b1b',
40
+ };
41
+
42
+ function initHeatmap() {
43
+ heatmapCanvas = document.getElementById('heatmap-canvas');
44
+ if (!heatmapCanvas) return;
45
+
46
+ heatmapCtx = heatmapCanvas.getContext('2d');
47
+ resizeCanvas();
48
+ window.addEventListener('resize', resizeCanvas);
49
+
50
+ // Initial fetch
51
+ fetchHeatmapData();
52
+
53
+ // Auto-refresh every 3 seconds
54
+ setInterval(fetchHeatmapData, 3000);
55
+ }
56
+
57
+ function resizeCanvas() {
58
+ if (!heatmapCanvas) return;
59
+ const rect = heatmapCanvas.parentElement.getBoundingClientRect();
60
+ heatmapCanvas.width = rect.width;
61
+ heatmapCanvas.height = rect.height;
62
+ drawHeatmap();
63
+ }
64
+
65
+ async function fetchHeatmapData() {
66
+ try {
67
+ const data = await apiCall('/api/crowd/heatmap');
68
+ heatmapData = data.zones || [];
69
+ drawHeatmap();
70
+ updateZoneCards(heatmapData);
71
+ } catch (e) {
72
+ console.error('Heatmap fetch error:', e);
73
+ }
74
+ }
75
+
76
+ function drawHeatmap() {
77
+ if (!heatmapCtx || !heatmapCanvas) return;
78
+
79
+ const W = heatmapCanvas.width;
80
+ const H = heatmapCanvas.height;
81
+
82
+ // Clear
83
+ heatmapCtx.fillStyle = '#111827';
84
+ heatmapCtx.fillRect(0, 0, W, H);
85
+
86
+ // Draw pitch (center ellipse)
87
+ heatmapCtx.save();
88
+ heatmapCtx.beginPath();
89
+ heatmapCtx.ellipse(W * 0.5, H * 0.5, W * 0.25, H * 0.3, 0, 0, Math.PI * 2);
90
+ heatmapCtx.fillStyle = '#1a3a2a';
91
+ heatmapCtx.fill();
92
+ heatmapCtx.strokeStyle = '#2a5a3a';
93
+ heatmapCtx.lineWidth = 2;
94
+ heatmapCtx.stroke();
95
+ heatmapCtx.restore();
96
+
97
+ // Draw pitch circle
98
+ heatmapCtx.beginPath();
99
+ heatmapCtx.ellipse(W * 0.5, H * 0.5, W * 0.08, H * 0.1, 0, 0, Math.PI * 2);
100
+ heatmapCtx.strokeStyle = '#2a5a3a';
101
+ heatmapCtx.lineWidth = 1;
102
+ heatmapCtx.stroke();
103
+
104
+ // Draw zones
105
+ heatmapData.forEach(zone => {
106
+ const pos = ZONE_POSITIONS[zone.id];
107
+ if (!pos) return;
108
+
109
+ const x = pos.x * W;
110
+ const y = pos.y * H;
111
+ const w = pos.w * W;
112
+ const h = pos.h * H;
113
+
114
+ // Zone fill with density color
115
+ const color = DENSITY_COLORS[zone.density_level] || '#22c55e';
116
+ const alpha = 0.3 + (zone.occupancy_rate / 100) * 0.5;
117
+
118
+ heatmapCtx.save();
119
+
120
+ // Glow effect
121
+ heatmapCtx.shadowColor = color;
122
+ heatmapCtx.shadowBlur = 15 * (zone.occupancy_rate / 100);
123
+
124
+ heatmapCtx.fillStyle = hexToRgba(color, alpha);
125
+ heatmapCtx.strokeStyle = hexToRgba(color, 0.6);
126
+ heatmapCtx.lineWidth = 1;
127
+
128
+ const rx = x - w / 2;
129
+ const ry = y - h / 2;
130
+
131
+ // Rounded rect
132
+ roundRect(heatmapCtx, rx, ry, w, h, 4);
133
+ heatmapCtx.fill();
134
+ heatmapCtx.stroke();
135
+
136
+ heatmapCtx.restore();
137
+
138
+ // Label
139
+ heatmapCtx.save();
140
+ heatmapCtx.fillStyle = '#f1f5f9';
141
+ heatmapCtx.font = `${Math.max(9, W * 0.012)}px Inter, sans-serif`;
142
+ heatmapCtx.textAlign = 'center';
143
+ heatmapCtx.textBaseline = 'middle';
144
+
145
+ const label = pos.label || zone.name;
146
+ const occ = `${Math.round(zone.occupancy_rate)}%`;
147
+
148
+ if (h > 25) {
149
+ heatmapCtx.fillText(label, x, y - 6);
150
+ heatmapCtx.font = `bold ${Math.max(10, W * 0.014)}px Inter, sans-serif`;
151
+ heatmapCtx.fillStyle = color;
152
+ heatmapCtx.fillText(occ, x, y + 8);
153
+ } else {
154
+ heatmapCtx.fillText(`${label} ${occ}`, x, y);
155
+ }
156
+
157
+ heatmapCtx.restore();
158
+ });
159
+
160
+ // Title
161
+ heatmapCtx.save();
162
+ heatmapCtx.fillStyle = '#94a3b8';
163
+ heatmapCtx.font = `600 ${Math.max(12, W * 0.016)}px Inter, sans-serif`;
164
+ heatmapCtx.textAlign = 'center';
165
+ heatmapCtx.fillText('๐Ÿ Wankhede Stadium โ€” Live Density', W / 2, H * 0.5);
166
+ heatmapCtx.restore();
167
+ }
168
+
169
+ function updateZoneCards(zones) {
170
+ zones.forEach(zone => {
171
+ const card = document.querySelector(`[data-zone-id="${zone.id}"]`);
172
+ if (!card) return;
173
+
174
+ const progressBar = card.querySelector('.progress-bar');
175
+ if (progressBar) {
176
+ progressBar.style.width = zone.occupancy_rate + '%';
177
+ progressBar.className = `progress-bar ${zone.density_level}`;
178
+ }
179
+
180
+ const dot = card.querySelector('.density-dot');
181
+ if (dot) {
182
+ dot.className = `density-dot ${zone.density_level}`;
183
+ }
184
+ });
185
+ }
186
+
187
+ // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
188
+
189
+ function hexToRgba(hex, alpha) {
190
+ const r = parseInt(hex.slice(1, 3), 16);
191
+ const g = parseInt(hex.slice(3, 5), 16);
192
+ const b = parseInt(hex.slice(5, 7), 16);
193
+ return `rgba(${r},${g},${b},${alpha})`;
194
+ }
195
+
196
+ function roundRect(ctx, x, y, w, h, r) {
197
+ ctx.beginPath();
198
+ ctx.moveTo(x + r, y);
199
+ ctx.lineTo(x + w - r, y);
200
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
201
+ ctx.lineTo(x + w, y + h - r);
202
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
203
+ ctx.lineTo(x + r, y + h);
204
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
205
+ ctx.lineTo(x, y + r);
206
+ ctx.quadraticCurveTo(x, y, x + r, y);
207
+ ctx.closePath();
208
+ }
209
+
210
+ // Toggle table view
211
+ document.addEventListener('DOMContentLoaded', () => {
212
+ initHeatmap();
213
+
214
+ const toggleBtn = document.getElementById('toggle-table-view');
215
+ if (toggleBtn) {
216
+ toggleBtn.addEventListener('click', () => {
217
+ const visual = document.getElementById('heatmap-visual');
218
+ const table = document.getElementById('heatmap-table');
219
+ if (table.style.display === 'none') {
220
+ table.style.display = 'block';
221
+ visual.style.display = 'none';
222
+ toggleBtn.textContent = '๐Ÿ—บ๏ธ Map View';
223
+ } else {
224
+ table.style.display = 'none';
225
+ visual.style.display = 'block';
226
+ toggleBtn.textContent = '๐Ÿ“‹ Table View';
227
+ }
228
+ });
229
+ }
230
+ });
231
+
232
+ console.log('๐Ÿ”ฅ Heatmap loaded');
static/js/notifications.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Notification Handler
3
+ */
4
+
5
+ let notifCheckInterval = null;
6
+
7
+ async function checkNotifications() {
8
+ try {
9
+ const data = await apiCall('/api/notifications/unread');
10
+ const countEl = document.getElementById('notif-count');
11
+ if (countEl) {
12
+ const count = data.count || 0;
13
+ countEl.textContent = count;
14
+ countEl.style.display = count > 0 ? 'flex' : 'none';
15
+ }
16
+ } catch (e) {
17
+ // Silent fail for notification polling
18
+ }
19
+ }
20
+
21
+ async function loadNotifications() {
22
+ try {
23
+ const notifications = await apiCall('/api/notifications?limit=10');
24
+ if (notifications && notifications.length > 0) {
25
+ // Show the latest unread as a toast
26
+ const unread = notifications.filter(n => !n.read);
27
+ if (unread.length > 0) {
28
+ const latest = unread[0];
29
+ showToast(latest.title, latest.message, latest.severity === 'critical' ? 'error' : latest.severity);
30
+ // Mark as read
31
+ await apiCall('/api/notifications/read', {
32
+ method: 'POST',
33
+ body: JSON.stringify({ id: latest.id }),
34
+ });
35
+ }
36
+ }
37
+ } catch (e) {
38
+ // Silent fail
39
+ }
40
+ }
41
+
42
+ // Listen for SSE notifications
43
+ document.addEventListener('venueflow:update', (e) => {
44
+ checkNotifications();
45
+ });
46
+
47
+ // Start periodic check
48
+ document.addEventListener('DOMContentLoaded', () => {
49
+ checkNotifications();
50
+ notifCheckInterval = setInterval(checkNotifications, 10000);
51
+
52
+ // Click handler for notification bell
53
+ const bell = document.getElementById('notification-bell');
54
+ if (bell) {
55
+ bell.addEventListener('click', loadNotifications);
56
+ }
57
+ });
58
+
59
+ console.log('๐Ÿ”” Notifications loaded');
static/js/queue.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Queue Management UI
3
+ */
4
+
5
+ // Filter queue stations by category
6
+ document.addEventListener('DOMContentLoaded', () => {
7
+ const filterBtns = document.querySelectorAll('.queue-filter');
8
+
9
+ filterBtns.forEach(btn => {
10
+ btn.addEventListener('click', () => {
11
+ const category = btn.dataset.category;
12
+
13
+ // Update active state
14
+ filterBtns.forEach(b => {
15
+ b.classList.remove('active');
16
+ b.classList.replace('btn-primary', 'btn-secondary');
17
+ b.setAttribute('aria-selected', 'false');
18
+ });
19
+ btn.classList.add('active');
20
+ btn.classList.replace('btn-secondary', 'btn-primary');
21
+ btn.setAttribute('aria-selected', 'true');
22
+
23
+ // Filter cards
24
+ const cards = document.querySelectorAll('#queue-stations-list .queue-card');
25
+ cards.forEach(card => {
26
+ if (category === 'all' || card.dataset.category === category) {
27
+ card.style.display = 'flex';
28
+ } else {
29
+ card.style.display = 'none';
30
+ }
31
+ });
32
+ });
33
+ });
34
+
35
+ // Auto-refresh queue data
36
+ setInterval(refreshQueues, 5000);
37
+ });
38
+
39
+ async function refreshQueues() {
40
+ try {
41
+ const data = await apiCall('/api/queue/summary');
42
+ if (!data || !data.stations) return;
43
+
44
+ data.stations.forEach(station => {
45
+ const card = document.querySelector(`[data-station-id="${station.id}"]`);
46
+ if (!card) return;
47
+
48
+ const waitEl = card.querySelector('.queue-wait');
49
+ if (waitEl) {
50
+ waitEl.textContent = Math.round(station.estimated_wait_minutes) + ' min';
51
+ waitEl.style.color = station.wait_color;
52
+ }
53
+
54
+ const metaEl = card.querySelector('.queue-meta');
55
+ if (metaEl) {
56
+ metaEl.textContent = `${station.category_label} โ€ข ${station.current_length} in line`;
57
+ }
58
+
59
+ const progressBar = card.querySelector('.progress-bar');
60
+ if (progressBar) {
61
+ const w = Math.min(station.estimated_wait_minutes * 3.3, 100);
62
+ progressBar.style.width = w + '%';
63
+ progressBar.className = `progress-bar ${station.wait_level}`;
64
+ }
65
+ });
66
+ } catch (e) {
67
+ // Silent fail for background refresh
68
+ }
69
+ }
70
+
71
+ async function joinQueue(stationId) {
72
+ try {
73
+ const ticket = await apiCall('/api/queue/join', {
74
+ method: 'POST',
75
+ body: JSON.stringify({ station_id: stationId }),
76
+ });
77
+
78
+ if (ticket && ticket.id) {
79
+ showToast(
80
+ 'Queue Joined! ๐ŸŽซ',
81
+ `Ticket #${ticket.id} โ€” Position ${ticket.position} at ${ticket.station_name}`,
82
+ 'success',
83
+ 8000
84
+ );
85
+
86
+ // Refresh the page to show updated tickets
87
+ setTimeout(() => location.reload(), 1500);
88
+ }
89
+ } catch (err) {
90
+ showToast('Could not join queue', err.message || 'Please try again', 'error');
91
+ }
92
+ }
93
+
94
+ async function cancelTicket(ticketId) {
95
+ if (!confirm('Cancel this virtual queue ticket?')) return;
96
+
97
+ try {
98
+ await apiCall('/api/queue/cancel', {
99
+ method: 'POST',
100
+ body: JSON.stringify({ ticket_id: ticketId }),
101
+ });
102
+
103
+ showToast('Ticket Cancelled', 'Your virtual queue spot has been released.', 'info');
104
+ setTimeout(() => location.reload(), 1000);
105
+ } catch (err) {
106
+ showToast('Could not cancel', err.message || 'Please try again', 'error');
107
+ }
108
+ }
109
+
110
+ console.log('โฑ๏ธ Queue UI loaded');
static/js/simulator-ui.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Simulator Control UI
3
+ */
4
+
5
+ let simRefreshInterval = null;
6
+
7
+ document.addEventListener('DOMContentLoaded', () => {
8
+ simRefreshInterval = setInterval(refreshSimStatus, 3000);
9
+ });
10
+
11
+ async function refreshSimStatus() {
12
+ try {
13
+ const status = await apiCall('/api/simulator/status');
14
+
15
+ updateEl('sim-tick', status.tick_count || 0);
16
+ updateEl('sim-speed', (status.speed || 1) + 'x');
17
+ updateEl('sim-occ', (status.venue_occupancy || 0) + '%');
18
+
19
+ const badge = document.getElementById('sim-status-badge');
20
+ if (badge) {
21
+ badge.textContent = status.running ? '๐ŸŸข Running' : 'โธ๏ธ Stopped';
22
+ badge.className = status.running ? 'badge badge-low' : 'badge badge-info';
23
+ }
24
+ } catch (e) {}
25
+ }
26
+
27
+ async function startSim() {
28
+ try {
29
+ await apiCall('/api/simulator/start', { method: 'POST' });
30
+ showToast('Simulation Started', 'Real-time data generation is active', 'success');
31
+ refreshSimStatus();
32
+ } catch (err) {
33
+ showToast('Error', err.message, 'error');
34
+ }
35
+ }
36
+
37
+ async function stopSim() {
38
+ try {
39
+ await apiCall('/api/simulator/stop', { method: 'POST' });
40
+ showToast('Simulation Stopped', 'Data generation paused', 'info');
41
+ refreshSimStatus();
42
+ } catch (err) {
43
+ showToast('Error', err.message, 'error');
44
+ }
45
+ }
46
+
47
+ async function setPhase(phase) {
48
+ try {
49
+ await apiCall('/api/simulator/phase', {
50
+ method: 'POST',
51
+ body: JSON.stringify({ phase }),
52
+ });
53
+ showToast('Phase Changed', `Event phase set to: ${phase.replace('_', ' ')}`, 'success');
54
+ refreshSimStatus();
55
+ } catch (err) {
56
+ showToast('Error', err.message, 'error');
57
+ }
58
+ }
59
+
60
+ async function setSpeed(speed) {
61
+ try {
62
+ const label = document.getElementById('speed-label');
63
+ if (label) label.textContent = speed + 'x';
64
+
65
+ await apiCall('/api/simulator/speed', {
66
+ method: 'POST',
67
+ body: JSON.stringify({ speed: parseFloat(speed) }),
68
+ });
69
+ } catch (err) {
70
+ showToast('Error', err.message, 'error');
71
+ }
72
+ }
73
+
74
+ function updateEl(id, value) {
75
+ const el = document.getElementById(id);
76
+ if (el) el.textContent = value;
77
+ }
78
+
79
+ console.log('๐ŸŽฎ Simulator UI loaded');
static/js/sse-client.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” SSE Client for Real-Time Updates
3
+ */
4
+
5
+ let sseConnection = null;
6
+ let sseReconnectAttempts = 0;
7
+ const SSE_MAX_RECONNECT = 10;
8
+
9
+ function connectSSE() {
10
+ if (sseConnection) {
11
+ sseConnection.close();
12
+ }
13
+
14
+ try {
15
+ sseConnection = new EventSource('/sse/stream');
16
+
17
+ sseConnection.onopen = () => {
18
+ console.log('๐Ÿ“ก SSE connected');
19
+ sseReconnectAttempts = 0;
20
+ };
21
+
22
+ sseConnection.addEventListener('update', (event) => {
23
+ try {
24
+ const data = JSON.parse(event.data);
25
+ handleSSEUpdate(data);
26
+ } catch (e) {
27
+ console.error('SSE parse error:', e);
28
+ }
29
+ });
30
+
31
+ sseConnection.addEventListener('heartbeat', () => {
32
+ // Keep-alive, no action needed
33
+ });
34
+
35
+ sseConnection.onerror = () => {
36
+ console.warn('๐Ÿ“ก SSE connection lost');
37
+ sseConnection.close();
38
+ sseConnection = null;
39
+
40
+ // Reconnect with backoff
41
+ if (sseReconnectAttempts < SSE_MAX_RECONNECT) {
42
+ const delay = Math.min(1000 * Math.pow(2, sseReconnectAttempts), 30000);
43
+ sseReconnectAttempts++;
44
+ setTimeout(connectSSE, delay);
45
+ }
46
+ };
47
+ } catch (e) {
48
+ console.error('SSE connection failed:', e);
49
+ }
50
+ }
51
+
52
+ function handleSSEUpdate(data) {
53
+ // Update KPI values if elements exist
54
+ if (data.venue) {
55
+ updateElement('kpi-attendance', data.venue.total_current);
56
+ updateElement('kpi-occupancy', data.venue.overall_occupancy + '%');
57
+ updateElement('stat-occupancy-val', data.venue.overall_occupancy + '%');
58
+ }
59
+
60
+ if (data.sim_status) {
61
+ updateElement('sim-tick', data.sim_status.tick_count);
62
+ updateElement('sim-occ', data.sim_status.venue_occupancy + '%');
63
+ }
64
+
65
+ // Trigger custom event for page-specific handlers
66
+ document.dispatchEvent(new CustomEvent('venueflow:update', { detail: data }));
67
+ }
68
+
69
+ function updateElement(id, value) {
70
+ const el = document.getElementById(id);
71
+ if (el) el.textContent = value;
72
+ }
73
+
74
+ // Start SSE on page load
75
+ document.addEventListener('DOMContentLoaded', () => {
76
+ connectSSE();
77
+ });
78
+
79
+ // Disconnect on page unload
80
+ window.addEventListener('beforeunload', () => {
81
+ if (sseConnection) sseConnection.close();
82
+ });
83
+
84
+ console.log('๐Ÿ“ก SSE client loaded');
static/js/wayfinding.js ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VenueFlow โ€” Wayfinding & Navigation
3
+ */
4
+
5
+ let navCanvas, navCtx;
6
+ let currentRoute = null;
7
+ let accessibleMode = false;
8
+ let selectedFromZone = 'gate_1'; // Default starting point
9
+ let pois = [];
10
+
11
+ // Same zone positions as heatmap
12
+ const NAV_ZONE_POSITIONS = {
13
+ north_stand: { x: 0.5, y: 0.15, label: 'North Stand', icon: '๐ŸŸ๏ธ' },
14
+ south_stand: { x: 0.5, y: 0.85, label: 'South Stand', icon: '๐ŸŸ๏ธ' },
15
+ east_pavilion: { x: 0.85, y: 0.5, label: 'East Pavilion', icon: '๐ŸŸ๏ธ' },
16
+ west_pavilion: { x: 0.15, y: 0.5, label: 'West Pavilion', icon: '๐ŸŸ๏ธ' },
17
+ main_pavilion: { x: 0.72, y: 0.25, label: 'VIP Pavilion', icon: 'โญ' },
18
+ north_concourse: { x: 0.5, y: 0.06, label: 'N Concourse', icon: '๐Ÿšถ' },
19
+ south_concourse: { x: 0.5, y: 0.94, label: 'S Concourse', icon: '๐Ÿšถ' },
20
+ east_concourse: { x: 0.95, y: 0.5, label: 'E Concourse', icon: '๐Ÿšถ' },
21
+ west_concourse: { x: 0.05, y: 0.5, label: 'W Concourse', icon: '๐Ÿšถ' },
22
+ gate_1: { x: 0.35, y: 0.02, label: 'Gate 1', icon: '๐Ÿšช' },
23
+ gate_3: { x: 0.65, y: 0.02, label: 'Gate 3', icon: '๐Ÿšช' },
24
+ gate_5: { x: 0.02, y: 0.5, label: 'Gate 5', icon: '๐Ÿšช' },
25
+ gate_7: { x: 0.5, y: 0.98, label: 'Gate 7', icon: '๐Ÿšช' },
26
+ food_north: { x: 0.6, y: 0.1, label: 'North Food', icon: '๐Ÿ•' },
27
+ food_south: { x: 0.4, y: 0.9, label: 'South Food', icon: '๐Ÿ•' },
28
+ food_west: { x: 0.1, y: 0.6, label: 'West Food', icon: '๐Ÿ•' },
29
+ restroom_n1: { x: 0.38, y: 0.1, label: 'Restroom N1', icon: '๐Ÿšป' },
30
+ restroom_s1: { x: 0.62, y: 0.9, label: 'Restroom S1', icon: '๐Ÿšป' },
31
+ restroom_e1: { x: 0.9, y: 0.35, label: 'Restroom E1', icon: '๐Ÿšป' },
32
+ restroom_w1: { x: 0.1, y: 0.65, label: 'Restroom W1', icon: '๐Ÿšป' },
33
+ parking_a: { x: 0.5, y: 0.0, label: 'Parking A', icon: '๐Ÿ…ฟ๏ธ' },
34
+ parking_b: { x: 0.0, y: 0.3, label: 'Parking B', icon: '๐Ÿ…ฟ๏ธ' },
35
+ };
36
+
37
+ function initNav() {
38
+ navCanvas = document.getElementById('nav-canvas');
39
+ if (!navCanvas) return;
40
+
41
+ navCtx = navCanvas.getContext('2d');
42
+ resizeNavCanvas();
43
+ window.addEventListener('resize', resizeNavCanvas);
44
+
45
+ // Click handler on canvas
46
+ navCanvas.addEventListener('click', handleCanvasClick);
47
+
48
+ drawNavMap();
49
+ }
50
+
51
+ function resizeNavCanvas() {
52
+ if (!navCanvas) return;
53
+ const rect = navCanvas.parentElement.getBoundingClientRect();
54
+ navCanvas.width = rect.width;
55
+ navCanvas.height = rect.height;
56
+ drawNavMap();
57
+ }
58
+
59
+ function handleCanvasClick(e) {
60
+ const rect = navCanvas.getBoundingClientRect();
61
+ const clickX = (e.clientX - rect.left) / rect.width;
62
+ const clickY = (e.clientY - rect.top) / rect.height;
63
+
64
+ // Find closest zone
65
+ let closest = null;
66
+ let closestDist = Infinity;
67
+
68
+ for (const [id, pos] of Object.entries(NAV_ZONE_POSITIONS)) {
69
+ const dist = Math.sqrt(Math.pow(clickX - pos.x, 2) + Math.pow(clickY - pos.y, 2));
70
+ if (dist < 0.05 && dist < closestDist) {
71
+ closest = id;
72
+ closestDist = dist;
73
+ }
74
+ }
75
+
76
+ if (closest) {
77
+ selectPOI(closest);
78
+ }
79
+ }
80
+
81
+ function drawNavMap() {
82
+ if (!navCtx || !navCanvas) return;
83
+
84
+ const W = navCanvas.width;
85
+ const H = navCanvas.height;
86
+
87
+ // Background
88
+ navCtx.fillStyle = '#111827';
89
+ navCtx.fillRect(0, 0, W, H);
90
+
91
+ // Cricket pitch
92
+ navCtx.save();
93
+ navCtx.beginPath();
94
+ navCtx.ellipse(W * 0.5, H * 0.5, W * 0.25, H * 0.3, 0, 0, Math.PI * 2);
95
+ navCtx.fillStyle = '#1a3a2a';
96
+ navCtx.fill();
97
+ navCtx.strokeStyle = '#2a5a3a';
98
+ navCtx.lineWidth = 2;
99
+ navCtx.stroke();
100
+ navCtx.restore();
101
+
102
+ // Draw zone markers
103
+ for (const [id, pos] of Object.entries(NAV_ZONE_POSITIONS)) {
104
+ const x = pos.x * W;
105
+ const y = pos.y * H;
106
+
107
+ // Marker circle
108
+ navCtx.save();
109
+ const isFrom = id === selectedFromZone;
110
+
111
+ navCtx.beginPath();
112
+ navCtx.arc(x, y, isFrom ? 14 : 10, 0, Math.PI * 2);
113
+ navCtx.fillStyle = isFrom ? '#6366f1' : '#1e2a3a';
114
+ navCtx.fill();
115
+ navCtx.strokeStyle = isFrom ? '#818cf8' : '#475569';
116
+ navCtx.lineWidth = 2;
117
+ navCtx.stroke();
118
+
119
+ // Icon
120
+ navCtx.font = `${isFrom ? 14 : 11}px serif`;
121
+ navCtx.textAlign = 'center';
122
+ navCtx.textBaseline = 'middle';
123
+ navCtx.fillText(pos.icon, x, y);
124
+
125
+ // Label below
126
+ navCtx.font = `500 ${Math.max(9, W * 0.01)}px Inter, sans-serif`;
127
+ navCtx.fillStyle = '#94a3b8';
128
+ navCtx.fillText(pos.label, x, y + 20);
129
+
130
+ navCtx.restore();
131
+ }
132
+
133
+ // Draw route if exists
134
+ if (currentRoute && currentRoute.path) {
135
+ drawRoute(currentRoute.path);
136
+ }
137
+ }
138
+
139
+ function drawRoute(path) {
140
+ if (!navCtx || path.length < 2) return;
141
+
142
+ const W = navCanvas.width;
143
+ const H = navCanvas.height;
144
+
145
+ navCtx.save();
146
+ navCtx.strokeStyle = '#06b6d4';
147
+ navCtx.lineWidth = 3;
148
+ navCtx.setLineDash([8, 4]);
149
+ navCtx.shadowColor = '#06b6d4';
150
+ navCtx.shadowBlur = 10;
151
+
152
+ navCtx.beginPath();
153
+ for (let i = 0; i < path.length; i++) {
154
+ const pos = NAV_ZONE_POSITIONS[path[i]];
155
+ if (!pos) continue;
156
+ const x = pos.x * W;
157
+ const y = pos.y * H;
158
+
159
+ if (i === 0) navCtx.moveTo(x, y);
160
+ else navCtx.lineTo(x, y);
161
+ }
162
+ navCtx.stroke();
163
+
164
+ // Destination marker
165
+ const dest = NAV_ZONE_POSITIONS[path[path.length - 1]];
166
+ if (dest) {
167
+ navCtx.beginPath();
168
+ navCtx.arc(dest.x * W, dest.y * H, 16, 0, Math.PI * 2);
169
+ navCtx.fillStyle = 'rgba(6, 182, 212, 0.3)';
170
+ navCtx.fill();
171
+ navCtx.strokeStyle = '#06b6d4';
172
+ navCtx.lineWidth = 3;
173
+ navCtx.setLineDash([]);
174
+ navCtx.stroke();
175
+ }
176
+
177
+ navCtx.restore();
178
+ }
179
+
180
+ async function selectPOI(zoneId) {
181
+ try {
182
+ const data = await apiCall(`/api/navigate/route?from=${selectedFromZone}&to=${zoneId}&accessible=${accessibleMode}`);
183
+ if (data) {
184
+ currentRoute = data;
185
+ showRouteDisplay(data);
186
+ drawNavMap();
187
+ }
188
+ } catch (err) {
189
+ showToast('Route Error', err.message || 'Could not find route', 'error');
190
+ }
191
+ }
192
+
193
+ async function findNearest(type) {
194
+ try {
195
+ const data = await apiCall(`/api/navigate/nearest?from=${selectedFromZone}&type=${type}&accessible=${accessibleMode}`);
196
+ if (data && data.route) {
197
+ currentRoute = data.route;
198
+ showRouteDisplay(data.route);
199
+ drawNavMap();
200
+ showToast('Nearest Found', `${data.target_zone.name} โ€” ${data.distance_m}m away`, 'success');
201
+ }
202
+ } catch (err) {
203
+ showToast('Not Found', `No ${type.replace('_', ' ')} found nearby`, 'warning');
204
+ }
205
+ }
206
+
207
+ function showRouteDisplay(route) {
208
+ const display = document.getElementById('route-display');
209
+ if (!display) return;
210
+
211
+ display.style.display = 'block';
212
+
213
+ document.getElementById('route-title').textContent = `${route.from} โ†’ ${route.to}`;
214
+ document.getElementById('route-distance').textContent = route.total_distance_m + 'm';
215
+ document.getElementById('route-time').textContent = route.estimated_time_display;
216
+
217
+ const stepsEl = document.getElementById('route-steps');
218
+ stepsEl.innerHTML = '';
219
+
220
+ if (route.steps && route.steps.length > 0) {
221
+ route.steps.forEach((step, i) => {
222
+ const item = document.createElement('div');
223
+ item.className = 'zone-item';
224
+ item.innerHTML = `
225
+ <span style="font-size:1.25rem;color:var(--accent-secondary);">${i + 1}</span>
226
+ <div class="zone-info">
227
+ <div class="zone-name">${escapeHtml(step.instruction)}</div>
228
+ <div class="zone-type">${step.distance_m}m</div>
229
+ </div>
230
+ <span style="font-size:1rem;">โ†’</span>
231
+ `;
232
+ stepsEl.appendChild(item);
233
+ });
234
+ } else {
235
+ stepsEl.innerHTML = '<div class="zone-item"><div class="zone-info"><div class="zone-name">Direct route available</div></div></div>';
236
+ }
237
+ }
238
+
239
+ function clearRoute() {
240
+ currentRoute = null;
241
+ const display = document.getElementById('route-display');
242
+ if (display) display.style.display = 'none';
243
+ drawNavMap();
244
+ }
245
+
246
+ function toggleAccessible() {
247
+ accessibleMode = !accessibleMode;
248
+ const btn = document.getElementById('accessible-route-btn');
249
+ if (btn) {
250
+ btn.setAttribute('aria-pressed', accessibleMode.toString());
251
+ if (accessibleMode) {
252
+ btn.classList.replace('btn-ghost', 'btn-primary');
253
+ showToast('Accessible Routing', 'Routes now prioritize elevators, ramps, and accessible paths', 'info');
254
+ } else {
255
+ btn.classList.replace('btn-primary', 'btn-ghost');
256
+ }
257
+ }
258
+
259
+ // Re-route if there's an active route
260
+ if (currentRoute) {
261
+ selectPOI(currentRoute.path[currentRoute.path.length - 1]);
262
+ }
263
+ }
264
+
265
+ document.addEventListener('DOMContentLoaded', initNav);
266
+
267
+ console.log('๐Ÿ—บ๏ธ Wayfinding loaded');
templates/attendee/heatmap.html ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import attendee_nav %}
3
+
4
+ {% block title %}Live Heatmap{% endblock %}
5
+
6
+ {% block navbar %}{{ attendee_nav('heatmap') }}{% endblock %}
7
+
8
+ {% block content %}
9
+ <div class="main-content">
10
+ <div class="card-header mb-4">
11
+ <div>
12
+ <h2>๐Ÿ”ฅ Live Crowd Heatmap</h2>
13
+ <p class="card-subtitle">Real-time density across the venue โ€” updated every 2 seconds</p>
14
+ </div>
15
+ <div class="flex gap-2">
16
+ <button id="toggle-table-view" class="btn btn-secondary btn-sm" aria-label="Toggle accessible table view">
17
+ ๐Ÿ“‹ Table View
18
+ </button>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- Heatmap Legend -->
23
+ <div class="flex gap-4 mb-4 items-center" style="flex-wrap:wrap;" role="legend" aria-label="Density legend">
24
+ <div class="flex items-center gap-2"><span class="density-dot low"></span><span style="font-size:var(--font-size-sm);">Low (&lt;40%)</span></div>
25
+ <div class="flex items-center gap-2"><span class="density-dot moderate"></span><span style="font-size:var(--font-size-sm);">Moderate (40-70%)</span></div>
26
+ <div class="flex items-center gap-2"><span class="density-dot high"></span><span style="font-size:var(--font-size-sm);">High (70-90%)</span></div>
27
+ <div class="flex items-center gap-2"><span class="density-dot critical"></span><span style="font-size:var(--font-size-sm);">Critical (&gt;90%)</span></div>
28
+ </div>
29
+
30
+ <!-- Visual Heatmap (Canvas-based) -->
31
+ <div id="heatmap-visual" class="card" style="padding:0;overflow:hidden;">
32
+ <div id="venue-map" class="map-container" style="height:520px;position:relative;background:var(--bg-secondary);">
33
+ <canvas id="heatmap-canvas" style="width:100%;height:100%;"></canvas>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Accessible Table View (hidden by default) -->
38
+ <div id="heatmap-table" class="card mt-4" style="display:none;">
39
+ <h3 class="card-title mb-4">Zone Density Data</h3>
40
+ <div style="overflow-x:auto;">
41
+ <table class="data-table" aria-label="Zone crowd density data">
42
+ <thead>
43
+ <tr>
44
+ <th scope="col">Status</th>
45
+ <th scope="col">Zone</th>
46
+ <th scope="col">Type</th>
47
+ <th scope="col">Capacity</th>
48
+ <th scope="col">Current</th>
49
+ <th scope="col">Occupancy</th>
50
+ <th scope="col">Available</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody id="heatmap-table-body">
54
+ {% for zone in heatmap_data.get('zones', []) %}
55
+ <tr>
56
+ <td><span class="density-dot {{ zone.density_level }}" aria-label="{{ zone.density_label }}"></span></td>
57
+ <td><strong>{{ zone.name }}</strong></td>
58
+ <td>{{ zone.zone_type | replace('_', ' ') | title }}</td>
59
+ <td>{{ zone.capacity }}</td>
60
+ <td>{{ zone.current_count }}</td>
61
+ <td>
62
+ <span class="badge badge-{{ zone.density_level }}">{{ zone.occupancy_rate }}%</span>
63
+ </td>
64
+ <td>{{ zone.available_capacity }}</td>
65
+ </tr>
66
+ {% endfor %}
67
+ </tbody>
68
+ </table>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Zone Detail Cards -->
73
+ <div class="content-grid grid-3 mt-6" id="zone-cards">
74
+ {% for zone in heatmap_data.get('zones', []) %}
75
+ <div class="stat-card" data-zone-id="{{ zone.id }}">
76
+ <div class="flex items-center gap-2">
77
+ <span class="density-dot {{ zone.density_level }}"></span>
78
+ <strong>{{ zone.name }}</strong>
79
+ </div>
80
+ <div class="progress" style="margin: var(--space-2) 0;">
81
+ <div class="progress-bar {{ zone.density_level }}" style="width: {{ zone.occupancy_rate }}%"></div>
82
+ </div>
83
+ <div class="flex justify-between" style="font-size:var(--font-size-xs);color:var(--text-tertiary);">
84
+ <span>{{ zone.current_count }} / {{ zone.capacity }}</span>
85
+ <span style="color:{{ zone.density_color }};font-weight:600;">{{ zone.occupancy_rate }}%</span>
86
+ </div>
87
+ </div>
88
+ {% endfor %}
89
+ </div>
90
+ </div>
91
+ {% endblock %}
92
+
93
+ {% block chatbot %}
94
+ {% include "components/chatbot.html" %}
95
+ {% endblock %}
96
+
97
+ {% block extra_js %}
98
+ <script src="{{ url_for('static', filename='js/heatmap.js') }}"></script>
99
+ <script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
100
+ <script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
101
+ <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
102
+ {% endblock %}
templates/attendee/home.html ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import attendee_nav %}
3
+
4
+ {% block title %}Home{% endblock %}
5
+
6
+ {% block navbar %}{{ attendee_nav('home') }}{% endblock %}
7
+
8
+ {% block content %}
9
+ <div class="main-content">
10
+ <!-- Hero Banner -->
11
+ <section class="hero-banner" aria-label="Event information">
12
+ <h2>{{ event.get('name', 'Live Event Today') }}</h2>
13
+ <p>{{ event.get('phase_description', 'Welcome to the venue') }}</p>
14
+ <div class="hero-event-info">
15
+ <div class="event-detail">๐Ÿ {{ event.get('sport', 'Cricket') }}</div>
16
+ <div class="event-detail">๐ŸŒค๏ธ {{ event.get('temperature_c', 28) }}ยฐC {{ event.get('weather', 'Clear') }}</div>
17
+ <div class="event-detail">
18
+ <span class="phase-badge">
19
+ <span class="phase-dot"></span>
20
+ {{ event.get('phase_label', 'Pre-Event') }}
21
+ </span>
22
+ </div>
23
+ <div class="event-detail">๐Ÿ‘ฅ {{ venue_summary.get('total_current', 0) | int }} / {{ venue_summary.get('total_capacity', 50000) | int }}</div>
24
+ </div>
25
+ </section>
26
+
27
+ <!-- Quick Stats -->
28
+ <section aria-label="Quick statistics">
29
+ <div class="content-grid grid-4 mb-6">
30
+ <div class="stat-card" id="stat-occupancy">
31
+ <span class="stat-icon">๐ŸŸ๏ธ</span>
32
+ <span class="stat-value" id="stat-occupancy-val">{{ venue_summary.get('overall_occupancy', 0) }}%</span>
33
+ <span class="stat-label">Venue Occupancy</span>
34
+ </div>
35
+
36
+ <div class="stat-card" id="stat-waiting">
37
+ <span class="stat-icon">โฑ๏ธ</span>
38
+ <span class="stat-value">{{ queue_summary.get('total_people_waiting', 0) }}</span>
39
+ <span class="stat-label">People in Queues</span>
40
+ </div>
41
+
42
+ <div class="stat-card" id="stat-zones-ok">
43
+ <span class="stat-icon">โœ…</span>
44
+ <span class="stat-value">{{ venue_summary.get('density_counts', {}).get('low', 0) }}</span>
45
+ <span class="stat-label">Low Density Zones</span>
46
+ </div>
47
+
48
+ <div class="stat-card" id="stat-zones-busy">
49
+ <span class="stat-icon">๐Ÿ”ด</span>
50
+ <span class="stat-value warning">{{ (venue_summary.get('density_counts', {}).get('high', 0) + venue_summary.get('density_counts', {}).get('critical', 0)) }}</span>
51
+ <span class="stat-label">Busy Zones</span>
52
+ </div>
53
+ </div>
54
+ </section>
55
+
56
+ <div class="content-grid grid-2">
57
+ <!-- Crowd Hotspots -->
58
+ <section class="card" aria-label="Busiest zones">
59
+ <div class="card-header">
60
+ <div>
61
+ <h3 class="card-title">๐Ÿ”ฅ Hotspot Zones</h3>
62
+ <p class="card-subtitle">Most crowded areas right now</p>
63
+ </div>
64
+ <a href="{{ url_for('attendee.heatmap') }}" class="btn btn-ghost btn-sm">View Map โ†’</a>
65
+ </div>
66
+ <div class="zone-list" id="hotspot-list">
67
+ {% for zone in venue_summary.get('hotspots', [])[:5] %}
68
+ <div class="zone-item">
69
+ <span class="density-dot {{ zone.density_level }}" aria-label="{{ zone.density_label }}"></span>
70
+ <div class="zone-info">
71
+ <div class="zone-name">{{ zone.name }}</div>
72
+ <div class="zone-type">{{ zone.zone_type | replace('_', ' ') }}</div>
73
+ </div>
74
+ <div class="zone-occupancy" style="color: {{ zone.density_color }}">
75
+ {{ zone.occupancy_rate }}%
76
+ </div>
77
+ </div>
78
+ {% endfor %}
79
+ </div>
80
+ </section>
81
+
82
+ <!-- Quick Queue Summary -->
83
+ <section class="card" aria-label="Queue overview">
84
+ <div class="card-header">
85
+ <div>
86
+ <h3 class="card-title">โฑ๏ธ Queue Status</h3>
87
+ <p class="card-subtitle">Shortest wait times by category</p>
88
+ </div>
89
+ <a href="{{ url_for('attendee.queues') }}" class="btn btn-ghost btn-sm">All Queues โ†’</a>
90
+ </div>
91
+ <div class="zone-list" id="queue-overview">
92
+ {% for cat_key, cat_data in queue_summary.get('categories', {}).items() %}
93
+ <div class="zone-item">
94
+ <span class="queue-icon" style="font-size:1.5rem;">
95
+ {% if cat_key == 'food' %}๐Ÿ•{% elif cat_key == 'restroom' %}๐Ÿšป{% elif cat_key == 'merch' %}๐Ÿ›๏ธ{% elif cat_key == 'entry' %}๐Ÿšช{% else %}๐Ÿ“{% endif %}
96
+ </span>
97
+ <div class="zone-info">
98
+ <div class="zone-name">{{ cat_key | replace('_', ' ') | title }}</div>
99
+ <div class="zone-type">Best: {{ cat_data.shortest_station or 'N/A' }}</div>
100
+ </div>
101
+ <div class="zone-occupancy" style="color: {% if cat_data.shortest_wait < 5 %}var(--density-low){% elif cat_data.shortest_wait < 15 %}var(--density-moderate){% else %}var(--density-high){% endif %}">
102
+ {{ cat_data.shortest_wait | round(0) | int }} min
103
+ </div>
104
+ </div>
105
+ {% endfor %}
106
+ </div>
107
+ </section>
108
+ </div>
109
+
110
+ <!-- Recommended Zones -->
111
+ <section class="card mt-6" aria-label="Recommended quiet zones">
112
+ <div class="card-header">
113
+ <div>
114
+ <h3 class="card-title">๐ŸŸข Quiet Zones</h3>
115
+ <p class="card-subtitle">Least crowded areas โ€” recommended for a comfortable experience</p>
116
+ </div>
117
+ </div>
118
+ <div class="content-grid grid-3">
119
+ {% for zone in venue_summary.get('most_available', [])[:3] %}
120
+ <div class="stat-card">
121
+ <span class="stat-icon">
122
+ {% if zone.zone_type == 'food_court' %}๐Ÿ•{% elif zone.zone_type == 'restroom' %}๐Ÿšป{% elif zone.zone_type == 'concourse' %}๐Ÿšถ{% elif zone.zone_type == 'stand' %}๐ŸŸ๏ธ{% else %}๐Ÿ“{% endif %}
123
+ </span>
124
+ <div>
125
+ <div style="font-weight:600;margin-bottom:4px;">{{ zone.name }}</div>
126
+ <div style="font-size:var(--font-size-sm);color:var(--text-tertiary);">{{ zone.zone_type | replace('_', ' ') | title }}</div>
127
+ </div>
128
+ <div class="flex items-center gap-2">
129
+ <span class="density-dot {{ zone.density_level }}"></span>
130
+ <span style="color:{{ zone.density_color }};font-weight:600;">{{ zone.occupancy_rate }}% full</span>
131
+ </div>
132
+ <div class="progress">
133
+ <div class="progress-bar {{ zone.density_level }}" style="width: {{ zone.occupancy_rate }}%"></div>
134
+ </div>
135
+ </div>
136
+ {% endfor %}
137
+ </div>
138
+ </section>
139
+ </div>
140
+ {% endblock %}
141
+
142
+ {% block chatbot %}
143
+ {% include "components/chatbot.html" %}
144
+ {% endblock %}
145
+
146
+ {% block extra_js %}
147
+ <script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
148
+ <script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
149
+ <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
150
+ <script>
151
+ // Auto-refresh data every 5 seconds
152
+ setInterval(async () => {
153
+ try {
154
+ const resp = await fetch('/api/crowd/summary');
155
+ if (resp.ok) {
156
+ const data = await resp.json();
157
+ const occEl = document.getElementById('stat-occupancy-val');
158
+ if (occEl) occEl.textContent = data.overall_occupancy + '%';
159
+ }
160
+ } catch(e) {}
161
+ }, 5000);
162
+ </script>
163
+ {% endblock %}
templates/attendee/navigate.html ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import attendee_nav %}
3
+
4
+ {% block title %}Navigate{% endblock %}
5
+
6
+ {% block navbar %}{{ attendee_nav('navigate') }}{% endblock %}
7
+
8
+ {% block content %}
9
+ <div class="main-content">
10
+ <div class="card-header mb-4">
11
+ <div>
12
+ <h2>๐Ÿ—บ๏ธ Venue Navigation</h2>
13
+ <p class="card-subtitle">Find your way around โ€” crowd-aware routing</p>
14
+ </div>
15
+ </div>
16
+
17
+ <!-- Quick Navigation Buttons -->
18
+ <div class="flex gap-3 mb-6" style="flex-wrap:wrap;">
19
+ <button class="btn btn-primary" onclick="findNearest('food_court')" id="find-food-btn">๐Ÿ• Nearest Food</button>
20
+ <button class="btn btn-secondary" onclick="findNearest('restroom')" id="find-restroom-btn">๐Ÿšป Nearest Restroom</button>
21
+ <button class="btn btn-secondary" onclick="findNearest('gate')" id="find-exit-btn">๐Ÿšช Nearest Exit</button>
22
+ <button class="btn btn-ghost" onclick="toggleAccessible()" id="accessible-route-btn" aria-pressed="false">โ™ฟ Accessible Routes</button>
23
+ </div>
24
+
25
+ <!-- Venue Map -->
26
+ <div class="card mb-6" style="padding:0;overflow:hidden;">
27
+ <div id="nav-map" class="map-container" style="height:480px;position:relative;background:var(--bg-secondary);">
28
+ <canvas id="nav-canvas" style="width:100%;height:100%;"></canvas>
29
+ </div>
30
+ </div>
31
+
32
+ <!-- Route Display -->
33
+ <div id="route-display" class="card mb-6" style="display:none;">
34
+ <div class="card-header">
35
+ <h3 class="card-title" id="route-title">Route</h3>
36
+ <button class="btn btn-ghost btn-sm" onclick="clearRoute()" aria-label="Clear route">โœ• Clear</button>
37
+ </div>
38
+ <div class="flex gap-4 mb-4">
39
+ <div class="stat-card" style="flex:1;">
40
+ <span class="stat-icon">๐Ÿ“</span>
41
+ <span class="stat-value" id="route-distance">โ€”</span>
42
+ <span class="stat-label">Distance</span>
43
+ </div>
44
+ <div class="stat-card" style="flex:1;">
45
+ <span class="stat-icon">โฑ๏ธ</span>
46
+ <span class="stat-value" id="route-time">โ€”</span>
47
+ <span class="stat-label">Est. Walking Time</span>
48
+ </div>
49
+ </div>
50
+ <div id="route-steps" class="zone-list" role="list" aria-label="Navigation steps"></div>
51
+ </div>
52
+
53
+ <!-- Points of Interest -->
54
+ <section class="card" aria-label="Points of interest">
55
+ <h3 class="card-title mb-4">๐Ÿ“ Points of Interest</h3>
56
+ <div class="content-grid grid-3" id="poi-grid">
57
+ {% for poi in pois %}
58
+ <div class="zone-item" style="cursor:pointer;" onclick="selectPOI('{{ poi.id }}')"
59
+ role="button" tabindex="0" aria-label="Navigate to {{ poi.name }}">
60
+ <span style="font-size:1.5rem;">{{ poi.icon }}</span>
61
+ <div class="zone-info">
62
+ <div class="zone-name">{{ poi.name }}</div>
63
+ <div class="zone-type">{{ poi.type | replace('_', ' ') | title }}</div>
64
+ </div>
65
+ <span class="density-dot {{ poi.density_level }}"></span>
66
+ </div>
67
+ {% endfor %}
68
+ </div>
69
+ </section>
70
+ </div>
71
+ {% endblock %}
72
+
73
+ {% block chatbot %}
74
+ {% include "components/chatbot.html" %}
75
+ {% endblock %}
76
+
77
+ {% block extra_js %}
78
+ <script src="{{ url_for('static', filename='js/wayfinding.js') }}"></script>
79
+ <script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
80
+ <script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
81
+ <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
82
+ {% endblock %}
templates/attendee/profile.html ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import attendee_nav %}
3
+
4
+ {% block title %}Profile & Settings{% endblock %}
5
+
6
+ {% block navbar %}{{ attendee_nav('profile') }}{% endblock %}
7
+
8
+ {% block content %}
9
+ <div class="main-content" style="max-width:700px;">
10
+ <h2 class="mb-6">โš™๏ธ Settings & Accessibility</h2>
11
+
12
+ <!-- Profile Info -->
13
+ <section class="card mb-6" aria-label="Profile information">
14
+ <h3 class="card-title mb-4">๐Ÿ‘ค Profile</h3>
15
+ <div class="form-group">
16
+ <label class="form-label">Display Name</label>
17
+ <input type="text" class="form-input" value="{{ user_name }}" disabled>
18
+ </div>
19
+ <div class="form-group">
20
+ <label class="form-label">Email</label>
21
+ <input type="email" class="form-input" value="{{ user_email }}" disabled>
22
+ </div>
23
+ </section>
24
+
25
+ <!-- Accessibility Settings -->
26
+ <section class="card mb-6" aria-label="Accessibility settings">
27
+ <h3 class="card-title mb-4">โ™ฟ Accessibility</h3>
28
+ <p class="card-subtitle" style="margin-bottom:var(--space-6);">Customize your experience for comfort and accessibility</p>
29
+
30
+ <div class="zone-list">
31
+ <div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('highContrast')" role="switch" aria-checked="false" tabindex="0" id="toggle-contrast">
32
+ <span style="font-size:1.5rem;">๐Ÿ”ฒ</span>
33
+ <div class="zone-info">
34
+ <div class="zone-name">High Contrast Mode</div>
35
+ <div class="zone-type">Increase text and element contrast for better visibility</div>
36
+ </div>
37
+ <div class="a11y-switch" id="switch-contrast" aria-hidden="true">OFF</div>
38
+ </div>
39
+
40
+ <div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('largeText')" role="switch" aria-checked="false" tabindex="0" id="toggle-text">
41
+ <span style="font-size:1.5rem;">๐Ÿ”ค</span>
42
+ <div class="zone-info">
43
+ <div class="zone-name">Large Text</div>
44
+ <div class="zone-type">Increase font size to 125% for easier reading</div>
45
+ </div>
46
+ <div class="a11y-switch" id="switch-text" aria-hidden="true">OFF</div>
47
+ </div>
48
+
49
+ <div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('reducedMotion')" role="switch" aria-checked="false" tabindex="0" id="toggle-motion">
50
+ <span style="font-size:1.5rem;">๐ŸŽž๏ธ</span>
51
+ <div class="zone-info">
52
+ <div class="zone-name">Reduced Motion</div>
53
+ <div class="zone-type">Disable animations and transitions</div>
54
+ </div>
55
+ <div class="a11y-switch" id="switch-motion" aria-hidden="true">OFF</div>
56
+ </div>
57
+ </div>
58
+ </section>
59
+
60
+ <!-- Language -->
61
+ <section class="card mb-6" aria-label="Language settings">
62
+ <h3 class="card-title mb-4">๐ŸŒ Language</h3>
63
+ <div class="form-group">
64
+ <label for="language-select" class="form-label">Preferred Language</label>
65
+ <select id="language-select" class="form-input" onchange="setLanguage(this.value)">
66
+ {% for code, name in languages.items() %}
67
+ <option value="{{ code }}">{{ name }}</option>
68
+ {% endfor %}
69
+ </select>
70
+ </div>
71
+ </section>
72
+ </div>
73
+ {% endblock %}
74
+
75
+ {% block extra_js %}
76
+ <script src="{{ url_for('static', filename='js/accessibility.js') }}"></script>
77
+ {% endblock %}
templates/attendee/queues.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import attendee_nav %}
3
+
4
+ {% block title %}Queue Status{% endblock %}
5
+
6
+ {% block navbar %}{{ attendee_nav('queues') }}{% endblock %}
7
+
8
+ {% block content %}
9
+ <div class="main-content">
10
+ <div class="card-header mb-4">
11
+ <div>
12
+ <h2>โฑ๏ธ Queue Status & Virtual Queue</h2>
13
+ <p class="card-subtitle">Skip physical lines โ€” book your spot in virtual queues</p>
14
+ </div>
15
+ </div>
16
+
17
+ <!-- Filter Tabs -->
18
+ <div class="flex gap-2 mb-6" style="flex-wrap:wrap;" role="tablist" aria-label="Queue category filter">
19
+ <button class="btn btn-primary btn-sm queue-filter active" data-category="all" role="tab" aria-selected="true">All</button>
20
+ <button class="btn btn-secondary btn-sm queue-filter" data-category="food" role="tab" aria-selected="false">๐Ÿ• Food</button>
21
+ <button class="btn btn-secondary btn-sm queue-filter" data-category="restroom" role="tab" aria-selected="false">๐Ÿšป Restrooms</button>
22
+ <button class="btn btn-secondary btn-sm queue-filter" data-category="merch" role="tab" aria-selected="false">๐Ÿ›๏ธ Merchandise</button>
23
+ <button class="btn btn-secondary btn-sm queue-filter" data-category="entry" role="tab" aria-selected="false">๐Ÿšช Gates</button>
24
+ </div>
25
+
26
+ <!-- Active Virtual Tickets -->
27
+ {% if user_tickets %}
28
+ <section class="card mb-6" aria-label="Your virtual queue tickets">
29
+ <h3 class="card-title mb-4">๐ŸŽซ Your Virtual Tickets</h3>
30
+ <div class="content-grid grid-2" id="my-tickets">
31
+ {% for ticket in user_tickets %}
32
+ {% if ticket.status == 'waiting' %}
33
+ <div class="queue-card" style="border-left:3px solid var(--accent-primary);">
34
+ <div class="queue-icon">{{ ticket.category_icon }}</div>
35
+ <div class="queue-details">
36
+ <div class="queue-name">{{ ticket.station_name }}</div>
37
+ <div class="queue-meta">Ticket #{{ ticket.id }} โ€ข Position {{ ticket.position }}</div>
38
+ </div>
39
+ <div>
40
+ <div class="queue-wait" style="color:var(--accent-primary);">#{{ ticket.position }}</div>
41
+ <button class="btn btn-ghost btn-sm" onclick="cancelTicket('{{ ticket.id }}')" aria-label="Cancel ticket {{ ticket.id }}">Cancel</button>
42
+ </div>
43
+ </div>
44
+ {% endif %}
45
+ {% endfor %}
46
+ </div>
47
+ </section>
48
+ {% endif %}
49
+
50
+ <!-- Queue Summary Stats -->
51
+ <div class="content-grid grid-3 mb-6">
52
+ <div class="stat-card">
53
+ <span class="stat-icon">๐Ÿ“Š</span>
54
+ <span class="stat-value">{{ queue_summary.get('total_stations', 0) }}</span>
55
+ <span class="stat-label">Open Stations</span>
56
+ </div>
57
+ <div class="stat-card">
58
+ <span class="stat-icon">๐Ÿ‘ฅ</span>
59
+ <span class="stat-value warning">{{ queue_summary.get('total_people_waiting', 0) }}</span>
60
+ <span class="stat-label">Total People Waiting</span>
61
+ </div>
62
+ <div class="stat-card">
63
+ <span class="stat-icon">โšก</span>
64
+ <span class="stat-value" id="shortest-wait-val">
65
+ {% set min_wait = namespace(val=999) %}
66
+ {% for s in queue_summary.get('stations', []) %}
67
+ {% if s.estimated_wait_minutes < min_wait.val %}
68
+ {% set min_wait.val = s.estimated_wait_minutes %}
69
+ {% endif %}
70
+ {% endfor %}
71
+ {{ min_wait.val | round(0) | int if min_wait.val < 999 else 0 }} min
72
+ </span>
73
+ <span class="stat-label">Shortest Wait</span>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- All Queue Stations -->
78
+ <section aria-label="All queue stations">
79
+ <div class="content-grid grid-2" id="queue-stations-list">
80
+ {% for station in queue_summary.get('stations', []) %}
81
+ <div class="queue-card" data-category="{{ station.category }}" data-station-id="{{ station.id }}">
82
+ <div class="queue-icon">{{ station.category_icon }}</div>
83
+ <div class="queue-details">
84
+ <div class="queue-name">{{ station.name }}</div>
85
+ <div class="queue-meta">{{ station.category_label }} โ€ข {{ station.current_length }} in line</div>
86
+ <div class="progress" style="margin-top:var(--space-2);max-width:200px;">
87
+ <div class="progress-bar {{ station.wait_level }}" style="width: {{ [station.estimated_wait_minutes * 3.3, 100] | min }}%"></div>
88
+ </div>
89
+ </div>
90
+ <div style="text-align:right;">
91
+ <div class="queue-wait" style="color:{{ station.wait_color }};">{{ station.estimated_wait_minutes | round(0) | int }} min</div>
92
+ <button class="btn btn-primary btn-sm mt-4" onclick="joinQueue('{{ station.id }}')"
93
+ aria-label="Join virtual queue for {{ station.name }}">
94
+ Join Queue
95
+ </button>
96
+ </div>
97
+ </div>
98
+ {% endfor %}
99
+ </div>
100
+ </section>
101
+ </div>
102
+ {% endblock %}
103
+
104
+ {% block chatbot %}
105
+ {% include "components/chatbot.html" %}
106
+ {% endblock %}
107
+
108
+ {% block extra_js %}
109
+ <script src="{{ url_for('static', filename='js/queue.js') }}"></script>
110
+ <script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
111
+ <script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
112
+ <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
113
+ {% endblock %}
templates/auth/login.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Login{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="auth-page">
7
+ <div class="auth-card">
8
+ <div class="text-center mb-6">
9
+ <div class="navbar-brand" style="justify-content: center; margin-bottom: var(--space-4);">
10
+ <span class="logo-icon">๐ŸŸ๏ธ</span>
11
+ <span>VenueFlow</span>
12
+ </div>
13
+ <h1>Welcome Back</h1>
14
+ <p class="auth-subtitle">Sign in to access your venue experience</p>
15
+ </div>
16
+
17
+ {% with messages = get_flashed_messages(with_categories=true) %}
18
+ {% if messages %}
19
+ <div class="flash-messages" role="alert">
20
+ {% for category, message in messages %}
21
+ <div class="flash-msg {{ category }}" role="alert">{{ message }}</div>
22
+ {% endfor %}
23
+ </div>
24
+ {% endif %}
25
+ {% endwith %}
26
+
27
+ <form method="POST" action="{{ url_for('auth.login') }}" novalidate>
28
+ <div class="form-group">
29
+ <label for="email" class="form-label">Email Address</label>
30
+ <input type="email" id="email" name="email" class="form-input"
31
+ placeholder="you@example.com" required autocomplete="email"
32
+ aria-required="true">
33
+ </div>
34
+
35
+ <div class="form-group">
36
+ <label for="password" class="form-label">Password</label>
37
+ <input type="password" id="password" name="password" class="form-input"
38
+ placeholder="Enter your password" required autocomplete="current-password"
39
+ aria-required="true">
40
+ </div>
41
+
42
+ <button type="submit" id="login-btn" class="btn btn-primary btn-block btn-lg">
43
+ Sign In
44
+ </button>
45
+ </form>
46
+
47
+ <div class="auth-footer">
48
+ <p style="margin-bottom: var(--space-3);">Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a></p>
49
+ <div style="border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); margin-top: var(--space-4);">
50
+ <p style="font-size: var(--font-size-xs); color: var(--text-muted); margin-bottom: var(--space-2);">Demo Accounts</p>
51
+ <p style="font-size: var(--font-size-xs); color: var(--text-tertiary);">
52
+ <strong>Fan:</strong> fan@venueflow.demo / demo123<br>
53
+ <strong>Operator:</strong> operator@venueflow.demo / demo123
54
+ </p>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ {% endblock %}
templates/auth/register.html ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Register{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="auth-page">
7
+ <div class="auth-card">
8
+ <div class="text-center mb-6">
9
+ <div class="navbar-brand" style="justify-content: center; margin-bottom: var(--space-4);">
10
+ <span class="logo-icon">๐ŸŸ๏ธ</span>
11
+ <span>VenueFlow</span>
12
+ </div>
13
+ <h1>Create Account</h1>
14
+ <p class="auth-subtitle">Join VenueFlow for a smarter venue experience</p>
15
+ </div>
16
+
17
+ {% with messages = get_flashed_messages(with_categories=true) %}
18
+ {% if messages %}
19
+ <div class="flash-messages" role="alert">
20
+ {% for category, message in messages %}
21
+ <div class="flash-msg {{ category }}">{{ message }}</div>
22
+ {% endfor %}
23
+ </div>
24
+ {% endif %}
25
+ {% endwith %}
26
+
27
+ <form method="POST" action="{{ url_for('auth.register') }}" novalidate>
28
+ <div class="form-group">
29
+ <label for="display_name" class="form-label">Display Name</label>
30
+ <input type="text" id="display_name" name="display_name" class="form-input"
31
+ placeholder="Your name" required aria-required="true">
32
+ </div>
33
+
34
+ <div class="form-group">
35
+ <label for="email" class="form-label">Email Address</label>
36
+ <input type="email" id="email" name="email" class="form-input"
37
+ placeholder="you@example.com" required autocomplete="email" aria-required="true">
38
+ </div>
39
+
40
+ <div class="form-group">
41
+ <label for="password" class="form-label">Password</label>
42
+ <input type="password" id="password" name="password" class="form-input"
43
+ placeholder="At least 6 characters" required autocomplete="new-password"
44
+ aria-required="true" minlength="6">
45
+ </div>
46
+
47
+ <div class="form-group">
48
+ <label for="confirm_password" class="form-label">Confirm Password</label>
49
+ <input type="password" id="confirm_password" name="confirm_password" class="form-input"
50
+ placeholder="Repeat your password" required autocomplete="new-password"
51
+ aria-required="true">
52
+ </div>
53
+
54
+ <button type="submit" id="register-btn" class="btn btn-primary btn-block btn-lg">
55
+ Create Account
56
+ </button>
57
+ </form>
58
+
59
+ <div class="auth-footer">
60
+ <p>Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></p>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-high-contrast="false" data-large-text="false" data-reduced-motion="false">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="VenueFlow โ€” Smart crowd management for sporting venues. Real-time heatmaps, virtual queues, and AI-powered navigation.">
7
+ <meta name="theme-color" content="#0a0e1a">
8
+ <title>{% block title %}VenueFlow{% endblock %} โ€” Smart Venue Experience</title>
9
+
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
11
+ {% block extra_css %}{% endblock %}
12
+
13
+ <!-- Preload critical font -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ </head>
17
+ <body>
18
+ <!-- Skip to Content (A11y) -->
19
+ <a href="#main-content" class="skip-link">Skip to main content</a>
20
+
21
+ {% block navbar %}{% endblock %}
22
+
23
+ <main id="main-content" role="main">
24
+ {% block content %}{% endblock %}
25
+ </main>
26
+
27
+ <!-- Toast Notification Container -->
28
+ <div id="toast-container" class="toast-container" role="alert" aria-live="polite" aria-label="Notifications"></div>
29
+
30
+ <!-- Chatbot Widget -->
31
+ {% block chatbot %}{% endblock %}
32
+
33
+ <!-- Mobile Bottom Nav -->
34
+ {% block mobile_nav %}{% endblock %}
35
+
36
+ <script src="{{ url_for('static', filename='js/app.js') }}"></script>
37
+ {% block extra_js %}{% endblock %}
38
+
39
+ <script>
40
+ // Load accessibility preferences from localStorage
41
+ (function() {
42
+ const prefs = JSON.parse(localStorage.getItem('venueflow_a11y') || '{}');
43
+ if (prefs.highContrast) document.documentElement.dataset.highContrast = 'true';
44
+ if (prefs.largeText) document.documentElement.dataset.largeText = 'true';
45
+ if (prefs.reducedMotion) document.documentElement.dataset.reducedMotion = 'true';
46
+ })();
47
+ </script>
48
+ </body>
49
+ </html>
templates/components/chatbot.html ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Chatbot Widget Component -->
2
+ <button class="chatbot-toggle" id="chatbot-toggle" aria-label="Open AI assistant" aria-expanded="false">
3
+ ๐Ÿ’ฌ
4
+ </button>
5
+
6
+ <div class="chatbot-window" id="chatbot-window" role="dialog" aria-label="AI Assistant" aria-hidden="true">
7
+ <div class="chatbot-header">
8
+ <h4>๐Ÿค– VenueFlow Assistant</h4>
9
+ <button class="btn btn-ghost btn-sm" onclick="toggleChatbot()" aria-label="Close chat" style="color:#fff;font-size:1.25rem;min-width:44px;min-height:44px;">โœ•</button>
10
+ </div>
11
+
12
+ <div class="chatbot-messages" id="chat-messages" aria-live="polite" aria-label="Chat messages">
13
+ <div class="chat-message bot">
14
+ ๐Ÿ‘‹ Hi! I'm your venue assistant. I can help you find food, restrooms, check crowd levels, and navigate the venue. What do you need?
15
+ </div>
16
+ </div>
17
+
18
+ <div class="chat-suggestions" id="chat-suggestions">
19
+ <!-- Populated by JS -->
20
+ </div>
21
+
22
+ <div class="chatbot-input">
23
+ <label for="chat-input" class="sr-only">Type your message</label>
24
+ <input type="text" id="chat-input" placeholder="Ask me anything..."
25
+ aria-label="Type your message" autocomplete="off" maxlength="500">
26
+ <button id="chat-send" onclick="sendChatMessage()" aria-label="Send message">โžค</button>
27
+ </div>
28
+ </div>
templates/components/nav.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Reusable navigation component -->
2
+ <!-- Attendee Navigation -->
3
+ {% macro attendee_nav(active='home') %}
4
+ <nav class="navbar" role="navigation" aria-label="Main navigation">
5
+ <a href="{{ url_for('attendee.home') }}" class="navbar-brand">
6
+ <span class="logo-icon">๐ŸŸ๏ธ</span>
7
+ <span>VenueFlow</span>
8
+ </a>
9
+
10
+ <ul class="navbar-nav" role="menubar">
11
+ <li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" class="{{ 'active' if active == 'home' }}" id="nav-home">๐Ÿ  Home</a></li>
12
+ <li role="none"><a href="{{ url_for('attendee.heatmap') }}" role="menuitem" class="{{ 'active' if active == 'heatmap' }}" id="nav-heatmap">๐Ÿ”ฅ Heatmap</a></li>
13
+ <li role="none"><a href="{{ url_for('attendee.queues') }}" role="menuitem" class="{{ 'active' if active == 'queues' }}" id="nav-queues">โฑ๏ธ Queues</a></li>
14
+ <li role="none"><a href="{{ url_for('attendee.navigate') }}" role="menuitem" class="{{ 'active' if active == 'navigate' }}" id="nav-navigate">๐Ÿ—บ๏ธ Navigate</a></li>
15
+ <li role="none"><a href="{{ url_for('attendee.profile') }}" role="menuitem" class="{{ 'active' if active == 'profile' }}" id="nav-profile">โš™๏ธ Settings</a></li>
16
+ </ul>
17
+
18
+ <div class="navbar-user">
19
+ <div id="notification-bell" class="btn btn-ghost" style="position:relative;font-size:1.25rem;cursor:pointer;" aria-label="Notifications" role="button" tabindex="0">
20
+ ๐Ÿ””
21
+ <span id="notif-count" class="badge badge-high" style="position:absolute;top:2px;right:2px;font-size:10px;min-width:18px;height:18px;display:none;align-items:center;justify-content:center;">0</span>
22
+ </div>
23
+ <div class="navbar-avatar" aria-hidden="true">{{ user_name[0] | upper if user_name else 'G' }}</div>
24
+ <a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
25
+ </div>
26
+ </nav>
27
+
28
+ <!-- Mobile Bottom Nav -->
29
+ <nav class="mobile-nav" role="navigation" aria-label="Mobile navigation">
30
+ <ul class="mobile-nav-items" role="menubar">
31
+ <li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" class="{{ 'active' if active == 'home' }}"><span class="nav-icon">๐Ÿ </span>Home</a></li>
32
+ <li role="none"><a href="{{ url_for('attendee.heatmap') }}" role="menuitem" class="{{ 'active' if active == 'heatmap' }}"><span class="nav-icon">๐Ÿ”ฅ</span>Heatmap</a></li>
33
+ <li role="none"><a href="{{ url_for('attendee.queues') }}" role="menuitem" class="{{ 'active' if active == 'queues' }}"><span class="nav-icon">โฑ๏ธ</span>Queues</a></li>
34
+ <li role="none"><a href="{{ url_for('attendee.navigate') }}" role="menuitem" class="{{ 'active' if active == 'navigate' }}"><span class="nav-icon">๐Ÿ—บ๏ธ</span>Navigate</a></li>
35
+ <li role="none"><a href="{{ url_for('attendee.profile') }}" role="menuitem" class="{{ 'active' if active == 'profile' }}"><span class="nav-icon">โš™๏ธ</span>Settings</a></li>
36
+ </ul>
37
+ </nav>
38
+ {% endmacro %}
39
+
40
+
41
+ <!-- Operator Navigation -->
42
+ {% macro operator_nav(active='dashboard') %}
43
+ <nav class="navbar" role="navigation" aria-label="Operator navigation">
44
+ <a href="{{ url_for('operator.dashboard') }}" class="navbar-brand">
45
+ <span class="logo-icon">๐ŸŸ๏ธ</span>
46
+ <span>VenueFlow <span style="font-size:var(--font-size-xs);color:var(--accent-secondary);font-weight:500;">OPS</span></span>
47
+ </a>
48
+
49
+ <ul class="navbar-nav" role="menubar">
50
+ <li role="none"><a href="{{ url_for('operator.dashboard') }}" role="menuitem" class="{{ 'active' if active == 'dashboard' }}" id="nav-dashboard">๐Ÿ“Š Dashboard</a></li>
51
+ <li role="none"><a href="{{ url_for('operator.analytics') }}" role="menuitem" class="{{ 'active' if active == 'analytics' }}" id="nav-analytics">๐Ÿ“ˆ Analytics</a></li>
52
+ <li role="none"><a href="{{ url_for('operator.alerts') }}" role="menuitem" class="{{ 'active' if active == 'alerts' }}" id="nav-alerts">๐Ÿšจ Alerts</a></li>
53
+ <li role="none"><a href="{{ url_for('operator.simulator_view') }}" role="menuitem" class="{{ 'active' if active == 'simulator' }}" id="nav-simulator">๐ŸŽฎ Simulator</a></li>
54
+ <li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" id="nav-fan-view">๐Ÿ‘ค Fan View</a></li>
55
+ </ul>
56
+
57
+ <div class="navbar-user">
58
+ <div id="notification-bell" class="btn btn-ghost" style="position:relative;font-size:1.25rem;cursor:pointer;" aria-label="Notifications" role="button" tabindex="0">
59
+ ๐Ÿ””
60
+ <span id="notif-count" class="badge badge-high" style="position:absolute;top:2px;right:2px;font-size:10px;min-width:18px;height:18px;display:none;align-items:center;justify-content:center;">0</span>
61
+ </div>
62
+ <div class="navbar-avatar" aria-hidden="true" style="background: var(--gradient-warm);">{{ user_name[0] | upper if user_name else 'O' }}</div>
63
+ <a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
64
+ </div>
65
+ </nav>
66
+ {% endmacro %}
templates/errors/404.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Page Not Found{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-page">
5
+ <div class="auth-card text-center">
6
+ <div style="font-size:4rem;margin-bottom:var(--space-4);">๐ŸŸ๏ธ</div>
7
+ <h1 style="font-size:var(--font-size-4xl);">404</h1>
8
+ <p style="margin-top:var(--space-4);margin-bottom:var(--space-6);">Looks like you've wandered off the pitch! This page doesn't exist.</p>
9
+ <a href="/" class="btn btn-primary">Return to Home</a>
10
+ </div>
11
+ </div>
12
+ {% endblock %}
templates/errors/500.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Server Error{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-page">
5
+ <div class="auth-card text-center">
6
+ <div style="font-size:4rem;margin-bottom:var(--space-4);">โš ๏ธ</div>
7
+ <h1 style="font-size:var(--font-size-4xl);">500</h1>
8
+ <p style="margin-top:var(--space-4);margin-bottom:var(--space-6);">Something went wrong on our end. Our team has been notified.</p>
9
+ <a href="/" class="btn btn-primary">Return to Home</a>
10
+ </div>
11
+ </div>
12
+ {% endblock %}
templates/operator/alerts.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "components/nav.html" import operator_nav %}
3
+ {% block title %}Alerts{% endblock %}
4
+ {% block navbar %}{{ operator_nav('alerts') }}{% endblock %}
5
+ {% block content %}
6
+ <div class="main-content">
7
+ <div class="card-header mb-6">
8
+ <div>
9
+ <h2>๐Ÿšจ Alert Management</h2>
10
+ <p class="card-subtitle">Review and manage venue alerts</p>
11
+ </div>
12
+ <span class="badge badge-info">{{ alerts | length }} alerts</span>
13
+ </div>
14
+ {% if alerts %}
15
+ <div class="zone-list" id="alerts-list">
16
+ {% for alert in alerts %}
17
+ <div class="zone-item" style="border-left: 3px solid {{ alert.severity_color }};">
18
+ <span style="font-size:1.25rem;">{{ alert.severity_icon }}</span>
19
+ <div class="zone-info" style="flex:1;">
20
+ <div class="zone-name">{{ alert.title }}</div>
21
+ <div class="zone-type">{{ alert.message }}</div>
22
+ <div style="font-size:var(--font-size-xs);color:var(--text-muted);margin-top:2px;">{{ alert.timestamp }}</div>
23
+ </div>
24
+ <span class="badge badge-{{ alert.severity }}">{{ alert.severity | title }}</span>
25
+ </div>
26
+ {% endfor %}
27
+ </div>
28
+ {% else %}
29
+ <div class="card text-center" style="padding:var(--space-12);">
30
+ <p style="font-size:2rem;margin-bottom:var(--space-4);">โœ…</p>
31
+ <p style="font-size:var(--font-size-lg);font-weight:600;">No alerts right now</p>
32
+ <p style="color:var(--text-tertiary);">Alerts will appear here when crowd thresholds are breached.</p>
33
+ </div>
34
+ {% endif %}
35
+ </div>
36
+ {% endblock %}
37
+ {% block extra_js %}
38
+ <script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
39
+ <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
40
+ {% endblock %}