diff --git a/GUIDES/AUTO_THEME_SCHEDULING_GUIDE.md b/GUIDES/AUTO_THEME_SCHEDULING_GUIDE.md new file mode 100644 index 000000000..e2ed23873 --- /dev/null +++ b/GUIDES/AUTO_THEME_SCHEDULING_GUIDE.md @@ -0,0 +1,2583 @@ +# מדריך מימוש - תחלופת ערכות נושא אוטומטית לפי שעות ביממה + +**תאריך**: ינואר 2026 +**גרסה**: 1.1 +**סטטוס**: מדריך מימוש + +--- + +## 📋 תוכן עניינים + +1. [סקירה כללית](#סקירה-כללית) +2. [החלטות עיצוב מרכזיות](#החלטות-עיצוב-מרכזיות) +3. [ארכיטקטורה](#ארכיטקטורה) +4. [שלב 1: עדכון מבנה הנתונים](#שלב-1-עדכון-מבנה-הנתונים) +5. [שלב 2: יצירת API Backend](#שלב-2-יצירת-api-backend) +6. [שלב 3: מימוש הלוגיקה בצד הלקוח](#שלב-3-מימוש-הלוגיקה-בצד-הלקוח) +7. [שלב 4: עדכון ממשק המשתמש](#שלב-4-עדכון-ממשק-המשתמש) +8. [שלב 5: אינטגרציה עם המערכת הקיימת](#שלב-5-אינטגרציה-עם-המערכת-הקיימת) +9. [שלב 6: בדיקות](#שלב-6-בדיקות) +10. [שיקולי UX ונגישות](#שיקולי-ux-ונגישות) +11. [סיכום](#סיכום) + +--- + +## סקירה כללית + +### מה הפיצ'ר עושה? + +מאפשר למשתמשים להגדיר תחלופה אוטומטית בין ערכות נושא לפי שעות ביממה: + +- **ערכת יום** (Day Theme): מופעלת בשעות היום שהמשתמש הגדיר +- **ערכת לילה** (Night Theme): מופעלת בשעות הלילה שהמשתמש הגדיר +- **מצב ידני**: המשתמש יכול לכבות את האוטומציה ולבחור ערכה קבועה + +### דוגמה לתרחיש שימוש + +> יוסי מעדיף ערכה בהירה (Classic) ביום כדי לעבוד בסביבה מוארת, +> ובלילה הוא עובר לערכה כהה (Dark) כדי להפחית עומס על העיניים. +> הוא מגדיר: יום = 07:00-20:00, לילה = 20:00-07:00. + +### יתרונות + +- ✅ הפחתת עומס על העיניים בשעות הלילה +- ✅ התאמה אוטומטית ללא צורך בהחלפה ידנית +- ✅ גמישות מלאה לבחירת שעות וערכות +- ✅ תמיכה בכל סוגי הערכות (Built-in, Shared, Custom) + +--- + +## החלטות עיצוב מרכזיות + +לפני המימוש, חשוב להגדיר מספר החלטות מוצריות: + +### 1. מקור האמת לזמן: הלקוח (Client-Side) + +**הבעיה**: השרת רץ ב-UTC, אבל המשתמש רואה שעון מקומי. + +**ההחלטה**: +- **הלקוח** הוא מקור האמת לחישוב התקופה הנוכחית (יום/לילה) +- השרת **לא מחשב** ולא מעדכן את `ui_prefs.theme` בזמן שמירת הגדרות +- השרת רק שומר את ההגדרות ומחזיר אותן ללקוח + +**יתרונות**: +- ✅ המשתמש רואה את מה שהוא מצפה לראות לפי השעון שלו +- ✅ אין צורך לשמור timezone בהעדפות +- ✅ פשוט יותר למימוש ולתחזוקה + +### 2. טווח שעות יום: ללא חציית חצות + +**הבעיה**: אם המשתמש מגדיר יום = `20:00 → 07:00`, זה מבלבל - האם זה "יום" או "לילה"? + +**ההחלטה**: +- **שעות יום חייבות להיות רציפות** (day_start < day_end) +- לא מאפשרים הגדרת יום שחוצה חצות +- וולידציה חוסמת: אם `day_start >= day_end` → שגיאה + +**דוגמאות חוקיות**: +- יום: 06:00 → 20:00 ✅ +- יום: 08:00 → 18:00 ✅ +- יום: 00:00 → 12:00 ✅ (משמרת לילה הפוכה) + +**דוגמאות לא חוקיות**: +- יום: 20:00 → 07:00 ❌ (חוצה חצות) +- יום: 12:00 → 12:00 ❌ (0 דקות) + +### 3. התנהגות בעת שינוי ידני (Override) + +**הבעיה**: מה קורה אם המשתמש לוחץ ידנית על "כהה" בזמן שהתזמון פעיל? + +**ההחלטה**: **Override זמני עד המעבר הבא** +- המשתמש יכול לשנות ידנית בכל רגע +- השינוי הידני נשמר ב-`localStorage` כ-`manual_override` +- ברגע שמגיע זמן המעבר הבא, ה-override מתבטל והתזמון חוזר לפעול +- הודעת UI מיידעת: "התזמון פעיל. השינוי יחזיק עד XX:XX" + +### 4. שמירת מזהה ערכה מלא + +**הבעיה**: "custom" הוא לא מזהה ערכה אמיתי, אלא קטגוריה. + +**ההחלטה**: +- לשמור תמיד את המזהה המלא של הערכה +- פורמט: `builtin:`, `shared:`, `custom:` +- לוולידציה מול DB אם הערכה קיימת (shared/custom) + +**דוגמאות**: +```json +{ + "day_theme": "builtin:classic", + "night_theme": "builtin:dark" +} +// או +{ + "day_theme": "shared:abc123", + "night_theme": "custom:my-theme-uuid" +} +``` + +### 5. טיימר חכם במקום Polling + +**הבעיה**: `setInterval` כל דקה זה עובד, אבל לא אופטימלי. + +**ההחלטה**: +- לחשב את הזמן המדויק עד המעבר הבא +- להגדיר `setTimeout` לאירוע הספציפי +- לאחר כל מעבר, לחשב מחדש את הטיימר הבא +- Timer גיבוי כל 5 דקות למקרה של drift + +--- + +## ארכיטקטורה + +### תרשים זרימה + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Settings Page │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Enable Auto │ │ Day Theme │ │ Start/End Hours │ │ +│ │ Toggle │ │ Selector │ │ Time Pickers │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +└─────────┼────────────────┼─────────────────────┼─────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Layer (Flask) │ +│ │ +│ POST /api/theme-schedule │ +│ GET /api/theme-schedule │ +│ │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MongoDB │ +│ │ +│ users.ui_prefs.theme_schedule: { │ +│ enabled: true, │ +│ day_theme: "classic", │ +│ night_theme: "dark", │ +│ day_start: "07:00", │ +│ day_end: "20:00" │ +│ } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Client-Side Logic (JavaScript) │ +│ │ +│ 1. Load schedule settings on page load │ +│ 2. Calculate current period (day/night) │ +│ 3. Apply appropriate theme │ +│ 4. Set timer for next transition │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### מבנה הקבצים + +``` +webapp/ +├── themes_api.py # הוספת endpoints חדשים +├── static/ +│ └── js/ +│ └── theme-scheduler.js # לוגיקה צד לקוח (קובץ חדש) +└── templates/ + └── settings/ + └── theme_schedule.html # ממשק הגדרות (קובץ חדש) +``` + +--- + +## שלב 1: עדכון מבנה הנתונים + +### 1.1 סכמת MongoDB + +הוסף לאובייקט `ui_prefs` של המשתמש: + +```javascript +// users collection - ui_prefs schema extension +{ + "user_id": 123456, + "ui_prefs": { + "theme": "classic", // ערכה נוכחית (קיים) + "font_scale": 1.0, // (קיים) + + // 🆕 הגדרות תזמון ערכות + "theme_schedule": { + "enabled": false, // האם התזמון מופעל + "day_theme": "builtin:classic", // ערכת יום (מזהה מלא) + "night_theme": "builtin:dark", // ערכת לילה (מזהה מלא) + "day_start": "07:00", // שעת התחלת יום (HH:MM) + "day_end": "20:00" // שעת סיום יום (HH:MM) - חייב להיות > day_start + } + } +} +``` + +**פורמט מזהה ערכה**: +- `builtin:` - ערכות מובנות (classic, dark, dim, etc.) +- `shared:` - ערכות ציבוריות מהספרייה +- `custom:` - ערכות מותאמות אישית של המשתמש + +### 1.2 ערכי ברירת מחדל + +```python +# services/constants.py או webapp/themes_api.py + +DEFAULT_THEME_SCHEDULE = { + "enabled": False, + "day_theme": "builtin:classic", + "night_theme": "builtin:dark", + "day_start": "07:00", + "day_end": "20:00", +} + +# ערכות נושא מובנות (Built-in) +BUILTIN_THEMES = { + "classic", "dark", "dim", "nebula", "ocean", + "forest", "rose-pine-dawn", "high-contrast" +} + +# Prefixes חוקיים למזהה ערכה +VALID_THEME_PREFIXES = ("builtin:", "shared:", "custom:") +``` + +### 1.3 וולידציה + +```python +import re +from datetime import datetime +from typing import Optional + +def validate_time_format(time_str: str) -> bool: + """בודק שפורמט השעה תקין (HH:MM).""" + if not time_str or not isinstance(time_str, str): + return False + pattern = r"^([01]?[0-9]|2[0-3]):([0-5][0-9])$" + return bool(re.match(pattern, time_str.strip())) + + +def time_to_minutes(time_str: str) -> int: + """ממיר מחרוזת שעה למספר דקות מחצות.""" + parts = time_str.strip().split(":") + return int(parts[0]) * 60 + int(parts[1]) + + +def validate_theme_identifier(theme_id: str, db=None, user_id: Optional[int] = None) -> tuple[bool, str]: + """ + מאמת מזהה ערכה מלא. + + Args: + theme_id: מזהה בפורמט prefix:value + db: חיבור ל-DB (אופציונלי, לבדיקת קיום) + user_id: מזהה משתמש (נדרש לבדיקת custom themes) + + Returns: + (is_valid, error_message) + """ + if not theme_id or not isinstance(theme_id, str): + return False, "missing_theme_id" + + theme_id = theme_id.strip().lower() + + # בדיקת prefix + if not any(theme_id.startswith(p) for p in VALID_THEME_PREFIXES): + return False, "invalid_theme_prefix" + + # בדיקת builtin + if theme_id.startswith("builtin:"): + name = theme_id.split(":", 1)[1] + if name not in BUILTIN_THEMES: + return False, "unknown_builtin_theme" + return True, "" + + # בדיקת shared (אופציונלי - נגד DB) + if theme_id.startswith("shared:"): + if db is not None: + shared_id = theme_id.split(":", 1)[1] + exists = db.shared_themes.find_one( + {"_id": shared_id, "is_active": True}, + {"_id": 1} + ) + if not exists: + return False, "shared_theme_not_found" + return True, "" + + # בדיקת custom (אופציונלי - נגד DB) + if theme_id.startswith("custom:"): + if db is not None and user_id: + custom_id = theme_id.split(":", 1)[1] + exists = db.users.find_one( + {"user_id": user_id, "custom_themes.id": custom_id}, + {"_id": 1} + ) + if not exists: + return False, "custom_theme_not_found" + return True, "" + + return False, "invalid_theme_id" + + +def validate_theme_schedule(schedule: dict, db=None, user_id: Optional[int] = None) -> tuple[bool, str]: + """ + מאמת את הגדרות תזמון הערכות. + + Returns: + (is_valid, error_message) + """ + if not isinstance(schedule, dict): + return False, "invalid_format" + + # בדיקת enabled + if "enabled" in schedule and not isinstance(schedule["enabled"], bool): + return False, "invalid_enabled_value" + + # בדיקת ערכות נושא + for key in ("day_theme", "night_theme"): + if key in schedule: + is_valid, error = validate_theme_identifier( + schedule[key], db=db, user_id=user_id + ) + if not is_valid: + return False, f"{key}_{error}" + + # בדיקת שעות + day_start = schedule.get("day_start") + day_end = schedule.get("day_end") + + if day_start is not None: + if not validate_time_format(day_start): + return False, "invalid_day_start_format" + + if day_end is not None: + if not validate_time_format(day_end): + return False, "invalid_day_end_format" + + # 🔒 וולידציה קריטית: day_start חייב להיות קטן מ-day_end + if day_start and day_end: + start_mins = time_to_minutes(day_start) + end_mins = time_to_minutes(day_end) + + if start_mins >= end_mins: + return False, "day_start_must_be_before_day_end" + + # מינימום שעה אחת של יום + if end_mins - start_mins < 60: + return False, "day_range_too_short" + + return True, "" +``` + +--- + +## שלב 2: יצירת API Backend + +### 2.1 הוספה ל-`themes_api.py` + +הוסף את הקוד הבא לקובץ `webapp/themes_api.py`: + +```python +# ============================================================ +# Theme Schedule API - תזמון ערכות לפי שעות +# ============================================================ + +DEFAULT_THEME_SCHEDULE = { + "enabled": False, + "day_theme": "classic", + "night_theme": "dark", + "day_start": "07:00", + "day_end": "20:00", +} + +ALLOWED_SCHEDULE_THEMES = { + "classic", "dark", "dim", "nebula", "ocean", + "forest", "rose-pine-dawn", "high-contrast", "custom" +} + + +def _validate_time_format(time_str: str) -> bool: + """בודק שפורמט השעה תקין (HH:MM).""" + if not time_str or not isinstance(time_str, str): + return False + import re + pattern = r"^([01]?[0-9]|2[0-3]):([0-5][0-9])$" + return bool(re.match(pattern, time_str.strip())) + + +def _validate_theme_schedule(schedule: dict) -> tuple[bool, str]: + """מאמת את הגדרות תזמון הערכות.""" + if not isinstance(schedule, dict): + return False, "invalid_format" + + if "enabled" in schedule and not isinstance(schedule["enabled"], bool): + return False, "invalid_enabled_value" + + for key in ("day_theme", "night_theme"): + if key in schedule: + theme = schedule[key] + if not isinstance(theme, str): + return False, f"invalid_{key}" + theme_lower = theme.lower().strip() + if not (theme_lower in ALLOWED_SCHEDULE_THEMES or + theme_lower.startswith("shared:") or + theme_lower == "custom"): + return False, f"invalid_{key}" + + for key in ("day_start", "day_end"): + if key in schedule: + if not _validate_time_format(schedule[key]): + return False, f"invalid_{key}_format" + + return True, "" + + +@themes_bp.route("/schedule", methods=["GET"]) +@require_auth +def get_theme_schedule(): + """ + קבלת הגדרות תזמון ערכות הנושא. + + Response: + { + "ok": true, + "schedule": { + "enabled": false, + "day_theme": "classic", + "night_theme": "dark", + "day_start": "07:00", + "day_end": "20:00" + } + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + try: + db_ref = get_db() + user_doc = db_ref.users.find_one( + {"user_id": int(user_id)}, + {"ui_prefs.theme_schedule": 1} + ) or {} + + ui_prefs = user_doc.get("ui_prefs") or {} + schedule = ui_prefs.get("theme_schedule") or {} + + # מיזוג עם ברירות מחדל + merged_schedule = {**DEFAULT_THEME_SCHEDULE, **schedule} + + return jsonify({"ok": True, "schedule": merged_schedule}) + + except Exception as e: + logger.exception("get_theme_schedule failed: %s", e) + return jsonify({"ok": False, "error": "database_error"}), 500 + + +@themes_bp.route("/schedule", methods=["POST"]) +@require_auth +def update_theme_schedule(): + """ + עדכון הגדרות תזמון ערכות הנושא. + + Request body: + { + "enabled": true, + "day_theme": "builtin:classic", + "night_theme": "builtin:dark", + "day_start": "07:00", + "day_end": "20:00" + } + + Response: + { + "ok": true, + "message": "הגדרות התזמון נשמרו", + "schedule": { ... } + } + + הערה חשובה: + - השרת לא מחשב את הערכה הנוכחית ולא מעדכן ui_prefs.theme + - הלקוח הוא מקור האמת לזמן (שעון מקומי של המשתמש) + - day_start חייב להיות קטן מ-day_end (אין תמיכה בטווח שחוצה חצות) + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + data = request.get_json(silent=True) or {} + + try: + db_ref = get_db() + + # וולידציה מלאה כולל בדיקת קיום ערכות ב-DB + is_valid, error_msg = _validate_theme_schedule( + data, db=db_ref, user_id=int(user_id) + ) + if not is_valid: + # מיפוי שגיאות לעברית + error_messages = { + "invalid_format": "פורמט לא תקין", + "invalid_enabled_value": "ערך enabled לא תקין", + "day_theme_missing_theme_id": "חסר מזהה ערכת יום", + "day_theme_invalid_theme_prefix": "פורמט ערכת יום לא תקין", + "day_theme_unknown_builtin_theme": "ערכת יום לא קיימת", + "day_theme_shared_theme_not_found": "ערכת יום שיתופית לא נמצאה", + "day_theme_custom_theme_not_found": "ערכת יום מותאמת לא נמצאה", + "night_theme_missing_theme_id": "חסר מזהה ערכת לילה", + "night_theme_invalid_theme_prefix": "פורמט ערכת לילה לא תקין", + "night_theme_unknown_builtin_theme": "ערכת לילה לא קיימת", + "night_theme_shared_theme_not_found": "ערכת לילה שיתופית לא נמצאה", + "night_theme_custom_theme_not_found": "ערכת לילה מותאמת לא נמצאה", + "invalid_day_start_format": "פורמט שעת התחלה לא תקין (נדרש HH:MM)", + "invalid_day_end_format": "פורמט שעת סיום לא תקין (נדרש HH:MM)", + "day_start_must_be_before_day_end": "שעת התחלה חייבת להיות לפני שעת הסיום", + "day_range_too_short": "טווח היום חייב להיות לפחות שעה", + } + message = error_messages.get(error_msg, error_msg) + return jsonify({"ok": False, "error": error_msg, "message": message}), 400 + + now_utc = datetime.now(timezone.utc) + + # קריאה קודמת לקבלת ערכים קיימים + user_doc = db_ref.users.find_one( + {"user_id": int(user_id)}, + {"ui_prefs.theme_schedule": 1} + ) or {} + + existing_schedule = (user_doc.get("ui_prefs") or {}).get("theme_schedule") or {} + + # מיזוג: ברירות מחדל <- קיים <- חדש + new_schedule = { + **DEFAULT_THEME_SCHEDULE, + **existing_schedule, + } + + # עדכון רק שדות שנשלחו + if "enabled" in data: + new_schedule["enabled"] = bool(data["enabled"]) + if "day_theme" in data: + new_schedule["day_theme"] = str(data["day_theme"]).strip().lower() + if "night_theme" in data: + new_schedule["night_theme"] = str(data["night_theme"]).strip().lower() + if "day_start" in data: + new_schedule["day_start"] = str(data["day_start"]).strip() + if "day_end" in data: + new_schedule["day_end"] = str(data["day_end"]).strip() + + # שמירה - ללא עדכון ui_prefs.theme (הלקוח יעשה זאת) + db_ref.users.update_one( + {"user_id": int(user_id)}, + { + "$set": { + "ui_prefs.theme_schedule": new_schedule, + "updated_at": now_utc, + } + }, + upsert=True, + ) + + return jsonify({ + "ok": True, + "message": "הגדרות התזמון נשמרו", + "schedule": new_schedule, + }) + + except Exception as e: + logger.exception("update_theme_schedule failed: %s", e) + return jsonify({"ok": False, "error": "database_error"}), 500 + + +def _extract_theme_name(theme_id: str) -> str: + """ + מחלץ את שם הערכה מהמזהה המלא. + לדוגמה: "builtin:dark" -> "dark", "shared:abc123" -> "shared:abc123" + """ + if not theme_id: + return "classic" + + if theme_id.startswith("builtin:"): + return theme_id.split(":", 1)[1] + + # עבור shared/custom, מחזירים את המזהה המלא (ה-JS יטפל בזה) + return theme_id +``` + +**הערה חשובה**: השרת לא מחשב את הערכה הנוכחית לפי זמן. כל החישובים מתבצעים בצד הלקוח (ראה שלב 3). + +--- + +## שלב 3: מימוש הלוגיקה בצד הלקוח + +### 3.1 יצירת `theme-scheduler.js` + +צור קובץ חדש: `webapp/static/js/theme-scheduler.js` + +```javascript +/** + * Theme Scheduler - תזמון אוטומטי של ערכות נושא לפי שעות + * + * עקרונות מרכזיים: + * 1. הלקוח הוא מקור האמת לזמן (שעון מקומי) + * 2. day_start < day_end תמיד (אין חציית חצות) + * 3. טיימר חכם - setTimeout לאירוע הבא, לא polling + * 4. תמיכה ב-override ידני זמני + */ + +(function() { + 'use strict'; + + // === קבועים === + const STORAGE_KEY = 'theme_schedule_cache'; + const OVERRIDE_KEY = 'theme_manual_override'; + const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 שעות + const BACKUP_CHECK_INTERVAL = 5 * 60 * 1000; // גיבוי כל 5 דקות + + // === מצב === + let currentSchedule = null; + let nextChangeTimer = null; + let backupTimer = null; + + // === עזר: זמן === + + /** + * המרת מחרוזת שעה למספר דקות מחצות + */ + function timeToMinutes(timeStr) { + if (!timeStr || typeof timeStr !== 'string') return 0; + const parts = timeStr.split(':'); + return (parseInt(parts[0], 10) || 0) * 60 + (parseInt(parts[1], 10) || 0); + } + + /** + * המרת דקות לפורמט שעה + */ + function formatMinutesToTime(minutes) { + const hours = Math.floor(minutes / 60) % 24; + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; + } + + /** + * קבלת השעה הנוכחית כמספר דקות + */ + function getCurrentMinutes() { + const now = new Date(); + return now.getHours() * 60 + now.getMinutes(); + } + + /** + * חישוב מספר מילישניות עד שעה מסוימת + */ + function getMillisecondsUntil(targetMinutes) { + const now = new Date(); + const currentMins = now.getHours() * 60 + now.getMinutes(); + const currentSecs = now.getSeconds(); + + let diffMins; + if (targetMinutes > currentMins) { + diffMins = targetMinutes - currentMins; + } else { + // מחר + diffMins = (24 * 60 - currentMins) + targetMinutes; + } + + // המרה למילישניות, מינוס השניות שכבר עברו + return (diffMins * 60 - currentSecs) * 1000; + } + + // === עזר: מזהה ערכה === + + /** + * חילוץ שם הערכה מהמזהה המלא + * "builtin:dark" -> "dark" + * "shared:abc123" -> נשאר כמו שהוא (נטפל ב-applyTheme) + */ + function extractThemeName(themeId) { + if (!themeId) return 'classic'; + + if (themeId.startsWith('builtin:')) { + return themeId.split(':', 2)[1]; + } + // shared/custom - מחזירים את ה-id המלא + return themeId; + } + + // === לוגיקה מרכזית === + + /** + * חישוב התקופה הנוכחית (יום/לילה) והערכה המתאימה + * + * הנחה: day_start < day_end (כבר עבר וולידציה בשרת) + */ + function calculateCurrentPeriod(schedule) { + if (!schedule || !schedule.enabled) { + return { period: null, theme: null, nextChangeIn: null, nextChangeAt: null }; + } + + const currentMins = getCurrentMinutes(); + const dayStart = timeToMinutes(schedule.day_start || '07:00'); + const dayEnd = timeToMinutes(schedule.day_end || '20:00'); + + // לוגיקה פשוטה: יום = בתוך הטווח [dayStart, dayEnd) + const isDay = currentMins >= dayStart && currentMins < dayEnd; + + // הערכה הנוכחית + const themeId = isDay ? schedule.day_theme : schedule.night_theme; + const theme = extractThemeName(themeId || (isDay ? 'builtin:classic' : 'builtin:dark')); + + // זמן המעבר הבא + const nextChangeAt = isDay ? dayEnd : dayStart; + const nextChangeIn = getMillisecondsUntil(nextChangeAt); + + return { + period: isDay ? 'day' : 'night', + theme: theme, + themeId: themeId, // המזהה המלא + nextChangeIn: nextChangeIn, // במילישניות + nextChangeAt: formatMinutesToTime(nextChangeAt), + }; + } + + // === Override ידני === + + /** + * בדיקה אם יש override ידני פעיל + */ + function getManualOverride() { + try { + const data = localStorage.getItem(OVERRIDE_KEY); + if (!data) return null; + + const override = JSON.parse(data); + const now = Date.now(); + + // בדיקה אם ה-override עדיין תקף + if (override.expiresAt && override.expiresAt > now) { + return override; + } + + // פג תוקף - מוחקים + localStorage.removeItem(OVERRIDE_KEY); + return null; + } catch (e) { + return null; + } + } + + /** + * הגדרת override ידני (זמני עד המעבר הבא) + */ + function setManualOverride(theme) { + if (!currentSchedule || !currentSchedule.enabled) return; + + const result = calculateCurrentPeriod(currentSchedule); + if (!result.nextChangeIn) return; + + const override = { + theme: theme, + setAt: Date.now(), + expiresAt: Date.now() + result.nextChangeIn, + expiresAtFormatted: result.nextChangeAt, + }; + + try { + localStorage.setItem(OVERRIDE_KEY, JSON.stringify(override)); + } catch (e) { + // ignore + } + + // הודעה למשתמש + showOverrideNotification(result.nextChangeAt); + } + + /** + * ביטול override ידני + */ + function clearManualOverride() { + try { + localStorage.removeItem(OVERRIDE_KEY); + } catch (e) { + // ignore + } + } + + /** + * הצגת הודעה על override + */ + function showOverrideNotification(expiresAt) { + // בדיקה אם כבר יש toast + const existing = document.querySelector('.theme-override-toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.className = 'theme-override-toast'; + toast.innerHTML = ` + + התזמון האוטומטי פעיל. השינוי הידני יתבטל ב-${expiresAt} + + `; + toast.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: var(--warning, #f59e0b); + color: #000; + padding: 0.75rem 1rem; + border-radius: 10px; + z-index: 9999; + display: flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + animation: fadeInUp 0.3s ease-out; + `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 5000); + } + + // === החלת ערכה === + + /** + * החלת ערכת נושא + */ + function applyTheme(theme, options = {}) { + if (!theme) return; + + const { source = 'scheduler', force = false } = options; + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme'); + + // בדיקת override ידני (רק אם לא force) + if (!force && source === 'scheduler') { + const override = getManualOverride(); + if (override) { + // יש override פעיל - לא משנים + console.log(`[ThemeScheduler] Manual override active until ${override.expiresAtFormatted}`); + return; + } + } + + // רק אם יש שינוי + if (currentTheme === theme && !force) return; + + console.log(`[ThemeScheduler] Applying theme: ${theme} (source: ${source})`); + + // עדכון ה-HTML attribute + html.setAttribute('data-theme', theme); + + // עדכון cookie (לטעינה הבאה) + try { + document.cookie = `ui_theme=${theme}; path=/; max-age=31536000; SameSite=Lax`; + } catch (e) { + // ignore + } + + // אירוע לעדכון קומפוננטים אחרים + window.dispatchEvent(new CustomEvent('themeChanged', { + detail: { theme, source } + })); + + // עדכון ה-DarkMode toggle button + updateToggleButton(theme); + } + + /** + * עדכון כפתור toggle + */ + function updateToggleButton(theme) { + const toggleBtn = document.getElementById('darkModeToggle'); + const icon = document.getElementById('darkModeIcon'); + if (!toggleBtn || !icon) return; + + const icons = { + 'classic': 'fa-sun', + 'dark': 'fa-moon', + 'dim': 'fa-cloud-moon', + 'ocean': 'fa-water', + 'forest': 'fa-tree', + 'nebula': 'fa-star', + }; + icon.className = 'fas ' + (icons[theme] || 'fa-palette'); + } + + // === מטמון === + + /** + * שמירה במטמון מקומי + */ + function saveToCache(schedule) { + try { + const cacheData = { + schedule: schedule, + fetchedAt: Date.now(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cacheData)); + } catch (e) { + // ignore + } + } + + /** + * טעינה ממטמון מקומי + */ + function loadFromCache() { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return null; + + const cached = JSON.parse(data); + + // בדיקת תוקף (24 שעות) + if (cached.fetchedAt && (Date.now() - cached.fetchedAt) > CACHE_MAX_AGE_MS) { + console.log('[ThemeScheduler] Cache expired, will fetch fresh data'); + return null; + } + + return cached.schedule; + } catch (e) { + return null; + } + } + + // === תקשורת עם השרת === + + /** + * טעינת הגדרות מהשרת + */ + async function loadSchedule() { + try { + const response = await fetch('/api/themes/schedule', { + method: 'GET', + credentials: 'same-origin', + }); + + if (!response.ok) { + console.warn('[ThemeScheduler] Failed to load schedule, using cache'); + return loadFromCache(); + } + + const data = await response.json(); + if (data.ok && data.schedule) { + currentSchedule = data.schedule; + saveToCache(data.schedule); + return data.schedule; + } + } catch (e) { + console.warn('[ThemeScheduler] Network error, using cache:', e.message); + } + + // Fallback למטמון + const cached = loadFromCache(); + if (cached) { + currentSchedule = cached; + } + return cached; + } + + /** + * שמירת הגדרות לשרת + */ + async function saveSchedule(schedule) { + try { + const response = await fetch('/api/themes/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(schedule), + }); + + const data = await response.json(); + if (data.ok) { + currentSchedule = data.schedule || schedule; + saveToCache(currentSchedule); + + // ביטול override קודם + clearManualOverride(); + + // עדכון מעקב + if (currentSchedule.enabled) { + startMonitoring(); + } else { + stopMonitoring(); + } + + return { success: true, schedule: currentSchedule }; + } else { + return { success: false, error: data.error, message: data.message }; + } + } catch (e) { + console.error('[ThemeScheduler] Save error:', e); + return { success: false, error: 'network_error' }; + } + } + + // === ניהול טיימרים === + + /** + * הגדרת טיימר לאירוע הבא + */ + function scheduleNextChange() { + // ניקוי טיימר קודם + if (nextChangeTimer) { + clearTimeout(nextChangeTimer); + nextChangeTimer = null; + } + + if (!currentSchedule || !currentSchedule.enabled) return; + + const result = calculateCurrentPeriod(currentSchedule); + if (!result.nextChangeIn) return; + + console.log(`[ThemeScheduler] Next change in ${Math.round(result.nextChangeIn / 60000)} minutes (at ${result.nextChangeAt})`); + + // טיימר לאירוע הבא + nextChangeTimer = setTimeout(() => { + console.log(`[ThemeScheduler] Time to switch!`); + + // ביטול override (הגיע זמן המעבר) + clearManualOverride(); + + // החלת הערכה החדשה + const newResult = calculateCurrentPeriod(currentSchedule); + if (newResult.theme) { + applyTheme(newResult.theme, { force: true }); + } + + // תזמון האירוע הבא + scheduleNextChange(); + + }, result.nextChangeIn + 1000); // +1 שנייה לוודא שעברנו את נקודת המעבר + } + + /** + * התחלת מעקב + */ + function startMonitoring() { + // עצירת טיימרים קודמים + stopMonitoring(); + + if (!currentSchedule || !currentSchedule.enabled) return; + + // החלת הערכה הנוכחית + const result = calculateCurrentPeriod(currentSchedule); + if (result.theme) { + applyTheme(result.theme); + } + + // תזמון המעבר הבא + scheduleNextChange(); + + // Timer גיבוי (למקרה של drift או חזרה מ-sleep) + backupTimer = setInterval(() => { + if (!currentSchedule?.enabled) return; + + const override = getManualOverride(); + if (override) { + // בדיקה אם ה-override פג + if (override.expiresAt <= Date.now()) { + clearManualOverride(); + const newResult = calculateCurrentPeriod(currentSchedule); + if (newResult.theme) { + applyTheme(newResult.theme, { force: true }); + } + } + return; + } + + // בדיקת התאמה + const result = calculateCurrentPeriod(currentSchedule); + const currentTheme = document.documentElement.getAttribute('data-theme'); + if (result.theme && result.theme !== currentTheme) { + console.log('[ThemeScheduler] Backup check detected mismatch, fixing...'); + applyTheme(result.theme); + scheduleNextChange(); + } + }, BACKUP_CHECK_INTERVAL); + } + + /** + * עצירת מעקב + */ + function stopMonitoring() { + if (nextChangeTimer) { + clearTimeout(nextChangeTimer); + nextChangeTimer = null; + } + if (backupTimer) { + clearInterval(backupTimer); + backupTimer = null; + } + } + + // === אתחול === + + async function init() { + console.log('[ThemeScheduler] Initializing...'); + + // טעינת הגדרות + await loadSchedule(); + + // התחלת מעקב אם מופעל + if (currentSchedule && currentSchedule.enabled) { + startMonitoring(); + } + } + + // הפעלה בטעינת הדף + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // האזנה לשינויי visibility (חזרה לטאב) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && currentSchedule?.enabled) { + // בדיקה אם צריך לעדכן + const override = getManualOverride(); + if (!override) { + const result = calculateCurrentPeriod(currentSchedule); + const currentTheme = document.documentElement.getAttribute('data-theme'); + if (result.theme && result.theme !== currentTheme) { + applyTheme(result.theme); + } + } + // רענון טיימר + scheduleNextChange(); + } + }); + + // === API גלובלי === + + window.ThemeScheduler = { + // פעולות בסיסיות + load: loadSchedule, + save: saveSchedule, + + // מצב נוכחי + getSchedule: () => currentSchedule, + getCurrentPeriod: () => calculateCurrentPeriod(currentSchedule), + isEnabled: () => currentSchedule?.enabled ?? false, + + // מעקב + start: startMonitoring, + stop: stopMonitoring, + + // Override ידני + setOverride: setManualOverride, + clearOverride: clearManualOverride, + getOverride: getManualOverride, + + // החלת ערכה (לשימוש חיצוני) + applyTheme: (theme) => applyTheme(theme, { source: 'manual' }), + }; + +})(); +``` + +### 3.2 הוספה ל-`base.html` + +הוסף את הקובץ אחרי `dark-mode.js`: + +```html + +{% if session.user_id %} + +{% endif %} +``` + +--- + +## שלב 4: עדכון ממשק המשתמש + +### 4.1 יצירת `theme_schedule.html` + +צור קובץ חדש: `webapp/templates/settings/theme_schedule.html` + +```html +{% extends "base.html" %} + +{% block title %}תזמון ערכות נושא - Code Keeper Bot{% endblock %} + +{% block content %} +

+ + תזמון ערכות נושא אוטומטי +

+ +
+
+
+

+ + + החלפה אוטומטית יום/לילה +

+

+ הגדר ערכות נושא שונות לשעות היום והלילה. + המערכת תחליף ביניהן אוטומטית. +

+
+ +
+ +
+ +
+ + + + + +
+ + +
+
+
+ + +
+

+ + איך זה עובד? +

+
    +
  • + + בחר ערכת נושא בהירה לשעות היום +
  • +
  • + + בחר ערכת נושא כהה לשעות הלילה +
  • +
  • + + הגדר את טווח השעות לפי ההעדפה שלך +
  • +
  • + + המערכת תחליף אוטומטית בין הערכות +
  • +
+

+ + השעות מבוססות על השעון המקומי של הדפדפן שלך. +

+
+
+ + +{% endblock %} + +{% block extra_js %} + + + +{% endblock %} +``` + +### 4.2 הוספת Route ב-`app.py` + +```python +@app.route('/settings/theme-schedule') +def theme_schedule_page(): + """דף הגדרות תזמון ערכות נושא.""" + if 'user_id' not in session: + return redirect(url_for('login')) + return render_template('settings/theme_schedule.html') +``` + +### 4.3 הוספת קישור בתפריט ההגדרות + +עדכן את `settings.html` או את התפריט הראשי: + +```html + + + תזמון ערכות נושא + חדש + +``` + +--- + +## שלב 5: אינטגרציה עם המערכת הקיימת + +### 5.1 עדכון `dark-mode.js` + +הוסף תמיכה בתזמון ו-override למודול הקיים: + +```javascript +// עדכן את הפונקציה toggleDarkMode() ב-dark-mode.js + +function toggleDarkMode() { + const current = loadPreference(); + let next; + switch (current) { + case 'auto': next = 'dark'; break; + case 'dark': next = 'dim'; break; + case 'dim': next = 'light'; break; + case 'light': + default: next = 'auto'; break; + } + + savePreference(next); + + // 🆕 אם יש תזמון פעיל, הגדר override זמני + if (typeof ThemeScheduler !== 'undefined' && ThemeScheduler.isEnabled()) { + const themeName = (next === 'auto') + ? (getSystemPreference() === 'dark' ? 'dark' : 'classic') + : (next === 'light' ? 'classic' : next); + + ThemeScheduler.setOverride(themeName); + applyTheme(themeName); + } else { + // התנהגות רגילה + if (loadPreference()) { updateTheme(); } + } + + updateToggleButton(next); + syncToServer(next); +} + +// עדכן את הפונקציה updateTheme() + +function updateTheme() { + // 🆕 בדיקה אם יש תזמון פעיל (ולא override) + if (typeof ThemeScheduler !== 'undefined' && ThemeScheduler.isEnabled()) { + const override = ThemeScheduler.getOverride(); + if (!override) { + // התזמון פעיל ואין override - ה-scheduler מטפל בזה + return; + } + // יש override - נמשיך לטפל כרגיל + } + + const preference = loadPreference(); + if (!preference) { + return; // אין העדפה שמורה - נכבד את ערך השרת + } + + if (preference === 'auto') { + applyTheme('auto'); + // ... האזנה לשינויי מערכת ... + } else { + const normalized = normalizePreferenceValue(preference); + applyTheme(normalized || preference); + } +} +``` + +### 5.2 עדכון `_inject_globals` ב-`app.py` + +הוסף את הגדרות התזמון ל-template context: + +```python +def _inject_globals(): + # ... קוד קיים ... + + # Theme Schedule + theme_schedule = None + try: + if user_id and user_doc: + ts = (user_doc.get('ui_prefs') or {}).get('theme_schedule') + if isinstance(ts, dict) and ts.get('enabled'): + theme_schedule = ts + except Exception: + theme_schedule = None + + return { + # ... שאר המשתנים ... + 'theme_schedule': theme_schedule, + } +``` + +### 5.3 הוספת Script ב-`base.html` למניעת FOUC + +הוסף ב-`` לפני טעינת CSS: + +```html + +``` + +--- + +## שלב 6: בדיקות + +### 6.1 Unit Tests + +צור קובץ: `tests/test_theme_schedule.py` + +```python +"""בדיקות למערכת תזמון ערכות נושא.""" + +import pytest +from datetime import datetime +from unittest.mock import patch, MagicMock + + +class TestTimeValidation: + """בדיקות וולידציה של שעות.""" + + def test_valid_time_format(self): + from webapp.themes_api import validate_time_format + + assert validate_time_format("07:00") is True + assert validate_time_format("23:59") is True + assert validate_time_format("00:00") is True + assert validate_time_format("12:30") is True + assert validate_time_format("9:00") is True # חד-ספרתי + + def test_invalid_time_format(self): + from webapp.themes_api import validate_time_format + + assert validate_time_format("25:00") is False + assert validate_time_format("12:60") is False + assert validate_time_format("abc") is False + assert validate_time_format("") is False + assert validate_time_format(None) is False + assert validate_time_format("12:00:00") is False # עם שניות + + +class TestThemeIdentifierValidation: + """בדיקות וולידציה של מזהי ערכות.""" + + def test_valid_builtin_themes(self): + from webapp.themes_api import validate_theme_identifier + + assert validate_theme_identifier("builtin:classic")[0] is True + assert validate_theme_identifier("builtin:dark")[0] is True + assert validate_theme_identifier("builtin:dim")[0] is True + assert validate_theme_identifier("builtin:nebula")[0] is True + + def test_invalid_builtin_theme(self): + from webapp.themes_api import validate_theme_identifier + + is_valid, error = validate_theme_identifier("builtin:nonexistent") + assert is_valid is False + assert error == "unknown_builtin_theme" + + def test_invalid_prefix(self): + from webapp.themes_api import validate_theme_identifier + + is_valid, error = validate_theme_identifier("invalid:theme") + assert is_valid is False + assert error == "invalid_theme_prefix" + + def test_shared_theme_format(self): + from webapp.themes_api import validate_theme_identifier + + # בלי DB, מחזיר תקין (לא יכולים לבדוק קיום) + assert validate_theme_identifier("shared:abc123")[0] is True + + def test_custom_theme_format(self): + from webapp.themes_api import validate_theme_identifier + + # בלי DB, מחזיר תקין + assert validate_theme_identifier("custom:my-uuid")[0] is True + + +class TestScheduleValidation: + """בדיקות וולידציה של תזמון מלא.""" + + def test_valid_schedule(self): + from webapp.themes_api import _validate_theme_schedule + + schedule = { + "enabled": True, + "day_theme": "builtin:classic", + "night_theme": "builtin:dark", + "day_start": "07:00", + "day_end": "20:00", + } + + is_valid, error = _validate_theme_schedule(schedule) + assert is_valid is True + assert error == "" + + def test_day_start_after_day_end_rejected(self): + """וולידציה: day_start חייב להיות לפני day_end.""" + from webapp.themes_api import _validate_theme_schedule + + schedule = { + "day_start": "20:00", + "day_end": "07:00", # לפני day_start! + } + + is_valid, error = _validate_theme_schedule(schedule) + assert is_valid is False + assert error == "day_start_must_be_before_day_end" + + def test_day_range_too_short(self): + """וולידציה: טווח יום מינימלי שעה.""" + from webapp.themes_api import _validate_theme_schedule + + schedule = { + "day_start": "12:00", + "day_end": "12:30", # רק 30 דקות + } + + is_valid, error = _validate_theme_schedule(schedule) + assert is_valid is False + assert error == "day_range_too_short" + + def test_same_start_and_end_rejected(self): + """וולידציה: אותה שעה התחלה וסיום.""" + from webapp.themes_api import _validate_theme_schedule + + schedule = { + "day_start": "12:00", + "day_end": "12:00", + } + + is_valid, error = _validate_theme_schedule(schedule) + assert is_valid is False + assert error == "day_start_must_be_before_day_end" + + +class TestAPIEndpoints: + """בדיקות API.""" + + @pytest.fixture + def client(self, app): + return app.test_client() + + def test_get_schedule_unauthorized(self, client): + """בדיקה שנדרש לוגין.""" + response = client.get('/api/themes/schedule') + assert response.status_code == 401 + + def test_get_schedule_authorized(self, client, logged_in_user): + """בדיקת קבלת הגדרות.""" + response = client.get('/api/themes/schedule') + assert response.status_code == 200 + data = response.get_json() + assert data['ok'] is True + assert 'schedule' in data + # בדיקת ערכי ברירת מחדל + assert data['schedule']['enabled'] is False + assert data['schedule']['day_theme'] == 'builtin:classic' + + def test_update_schedule_valid(self, client, logged_in_user): + """בדיקת עדכון הגדרות תקינות.""" + response = client.post('/api/themes/schedule', json={ + "enabled": True, + "day_theme": "builtin:ocean", + "night_theme": "builtin:dim", + "day_start": "08:00", + "day_end": "19:00", + }) + assert response.status_code == 200 + data = response.get_json() + assert data['ok'] is True + assert data['schedule']['enabled'] is True + + def test_update_schedule_invalid_time_range(self, client, logged_in_user): + """בדיקת דחיית טווח שעות לא תקין.""" + response = client.post('/api/themes/schedule', json={ + "day_start": "20:00", + "day_end": "08:00", # לפני day_start + }) + assert response.status_code == 400 + data = response.get_json() + assert data['ok'] is False + assert "day_start_must_be_before_day_end" in data['error'] + + def test_update_schedule_invalid_theme(self, client, logged_in_user): + """בדיקת דחיית ערכה לא קיימת.""" + response = client.post('/api/themes/schedule', json={ + "day_theme": "builtin:nonexistent", + }) + assert response.status_code == 400 + data = response.get_json() + assert data['ok'] is False + assert "unknown_builtin_theme" in data['error'] +``` + +### 6.2 Integration Tests + +```python +"""בדיקות אינטגרציה לתזמון ערכות.""" + +import pytest +from playwright.sync_api import Page + + +class TestThemeScheduleUI: + """בדיקות ממשק משתמש.""" + + def test_schedule_page_loads(self, page: Page, logged_in_session): + """בדיקה שדף ההגדרות נטען.""" + page.goto('/settings/theme-schedule') + assert page.locator('h1').text_content() == 'תזמון ערכות נושא אוטומטי' + + def test_toggle_enables_settings(self, page: Page, logged_in_session): + """בדיקה שההפעלה מציגה את ההגדרות.""" + page.goto('/settings/theme-schedule') + + settings = page.locator('#scheduleSettings') + assert settings.is_hidden() + + page.locator('#scheduleEnabled').click() + assert settings.is_visible() + + def test_save_schedule(self, page: Page, logged_in_session): + """בדיקת שמירת הגדרות.""" + page.goto('/settings/theme-schedule') + + page.locator('#scheduleEnabled').click() + page.locator('#dayTheme').select_option('ocean') + page.locator('#nightTheme').select_option('dim') + page.locator('#saveScheduleBtn').click() + + # בדיקה שהודעת הצלחה מוצגת + toast = page.locator('.toast-success') + assert toast.is_visible() +``` + +--- + +## שיקולי UX ונגישות + +### 7.1 נגישות (A11y) + +```html + + + +