From 6f90bd2891b2e2959a40154b2557248d55a35e9c Mon Sep 17 00:00:00 2001
From: Ettore <=>
Date: Fri, 22 May 2026 23:50:05 +0200
Subject: [PATCH] Add button for home screen add instructions
---
README.md | 22 ++++++++-
src/main.py | 7 ++-
src/static/app.js | 60 ++++++++++++++----------
src/static/images/add_to_home_screen.svg | 1 +
src/static/index.html | 7 ++-
src/static/style.css | 5 +-
6 files changed, 69 insertions(+), 33 deletions(-)
create mode 100644 src/static/images/add_to_home_screen.svg
diff --git a/README.md b/README.md
index df22786..50ca83e 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@ A web-based gate access management and control system. Authorized users can remo
## Features
-- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
+- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist, an optional expiration date, and an optional time/day-of-week schedule
+- **Keypass schedules** — restrict a keypass to specific days of the week and/or a time window (e.g. Monday–Friday 08:00–18:00, server local time)
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays
- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
@@ -93,7 +94,7 @@ data/
|---|---|---|
| GET | `/api/admin/keypasses` | List all keypasses |
| POST | `/api/admin/keypasses` | Create a keypass |
-| PATCH | `/api/admin/keypasses/{kp_id}` | Update a keypass |
+| PATCH | `/api/admin/keypasses/{kp_id}` | Update a keypass (description, expiry, gates, schedule) |
| DELETE | `/api/admin/keypasses/{kp_id}` | Revoke a keypass |
| GET | `/api/admin/keypasses/{kp_id}/qr` | Download QR code PNG for a keypass |
@@ -240,6 +241,23 @@ The Shelly server URI and auth key are stored encrypted. Configure them under **
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing.
+## Keypass Schedules
+
+In addition to an expiry date and a per-gate allowlist, each keypass can be restricted to a configurable time window:
+
+- **Days of the week** — select any combination of Mon–Sun. If no day is checked the restriction applies on any day.
+- **Time window** — a `From` / `To` time in 24-hour format (server local time). Both values must be provided together.
+
+When a keypass with a schedule is used outside its allowed window the API returns **HTTP 403** (`Keypass is not valid today` / `Keypass is not valid at this time`) and the gate will not open. The restriction is enforced in `require_keypass` in `src/core/dependencies.py`.
+
+The schedule is stored as a JSON object in the `schedule` column of the `keypasses` table:
+
+```json
+{ "days": [0, 1, 2, 3, 4], "time_start": "08:00", "time_end": "18:00" }
+```
+
+`days` uses Python's `weekday()` convention: 0 = Monday, 6 = Sunday. Any absent key is treated as unrestricted (e.g. omitting `days` means any day is allowed).
+
## Keypass QR Codes
Each active keypass has a **QR** button in the admin panel. Clicking it generates a PNG QR code that encodes the URL:
diff --git a/src/main.py b/src/main.py
index bf1be9d..4cf34ca 100644
--- a/src/main.py
+++ b/src/main.py
@@ -103,8 +103,11 @@ async def _security_headers(request: Request, call_next) -> Response:
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
- "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
- " img-src 'self' data: blob: https://*.tile.openstreetmap.org;"
+ "default-src 'self';"
+ " script-src 'self' https://cdn.jsdelivr.net;"
+ " style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com;"
+ " font-src 'self' https://fonts.gstatic.com;"
+ " img-src 'self' data: blob: https://*.tile.openstreetmap.org https://cdn.jsdelivr.net;"
" connect-src 'self' https://*.tile.openstreetmap.org"
)
return response
diff --git a/src/static/app.js b/src/static/app.js
index 328214f..dbe2a86 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -342,33 +342,43 @@ if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
-// ── PWA install banner ────────────────────────────────────────────────────────
+// ── Add to Home Screen ────────────────────────────────────────────────────────
+const _isInstalled = window.matchMedia("(display-mode: standalone)").matches || !!navigator.standalone;
+const _installAppBtn = document.getElementById("install-app-btn");
+const _installBanner = document.getElementById("install-banner");
const INSTALL_DISMISSED_KEY = "lg_install_dismissed";
-let _deferredInstallPrompt = null;
-window.addEventListener("beforeinstallprompt", (e) => {
- e.preventDefault();
- if (sessionStorage.getItem(INSTALL_DISMISSED_KEY)) return;
- _deferredInstallPrompt = e;
- document.getElementById("install-banner").classList.remove("hidden");
-});
+if (_isInstalled) {
+ // Already installed — hide both
+ _installAppBtn.style.display = "none";
+} else if (typeof window.AddToHomeScreen === "function") {
+ const _addToHomeScreenInstance = window.AddToHomeScreen({
+ appName: "Lagomare Gates",
+ appIconUrl: "/static/images/mobile_icon.png",
+ assetUrl: "https://cdn.jsdelivr.net/npm/pwa-add-to-homescreen@4/dist/assets/img/",
+ maxModalDisplayCount: -1,
+ displayOptions: { showMobile: true, showDesktop: true },
+ allowClose: true,
+ showArrow: true,
+ });
-document.getElementById("install-btn").addEventListener("click", async () => {
- const banner = document.getElementById("install-banner");
- banner.classList.add("hidden");
- if (!_deferredInstallPrompt) return;
- _deferredInstallPrompt.prompt();
- await _deferredInstallPrompt.userChoice;
- _deferredInstallPrompt = null;
-});
+ // Header button — always triggers the library UI
+ _installAppBtn.addEventListener("click", () => {
+ _addToHomeScreenInstance.show("en");
+ });
-document.getElementById("install-dismiss").addEventListener("click", () => {
- document.getElementById("install-banner").classList.add("hidden");
- sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
- _deferredInstallPrompt = null;
-});
+ // Auto-show banner once per session
+ if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) {
+ _installBanner.classList.remove("hidden");
+ }
-window.addEventListener("appinstalled", () => {
- document.getElementById("install-banner").classList.add("hidden");
- _deferredInstallPrompt = null;
-});
+ document.getElementById("install-btn").addEventListener("click", () => {
+ _installBanner.classList.add("hidden");
+ _addToHomeScreenInstance.show("en");
+ });
+
+ document.getElementById("install-dismiss").addEventListener("click", () => {
+ _installBanner.classList.add("hidden");
+ sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1");
+ });
+}
diff --git a/src/static/images/add_to_home_screen.svg b/src/static/images/add_to_home_screen.svg
new file mode 100644
index 0000000..1c8963f
--- /dev/null
+++ b/src/static/images/add_to_home_screen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/static/index.html b/src/static/index.html
index ff0aa1c..31c25f2 100644
--- a/src/static/index.html
+++ b/src/static/index.html
@@ -12,6 +12,7 @@
+