From ceb893a54c48673186a5de7302935e89e13d9747 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 16:37:38 +0000 Subject: [PATCH] feat: add 24-Hour (full-day) session period type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions could only be tagged day or night (weekday/weekend). 24/7 continuous jobs had no fitting period type. Add "24-Hour" (full_24h) — a single full-day period covering day + night. UI (session_list.html): - Full-width "24-Hour" button under the WD/WE x Day/Night grid; teal badge. - Selecting it clears + disables the hour inputs (no window); reopening an existing 24-Hour session opens with hours disabled. Badge current-period kept in sync after save. Backend (projects.py): - full_24h added to VALID_PERIOD_TYPES and the session-label maps ("... - 24-Hour"). Operator-set only; never auto-derived. - Combined report: include ALL rows for a 24-hour session (no day/night window filter) and split them by hour into the three non-overlapping buckets — Daytime 7-18:59, Evening 19-21:59, Nighttime 22:00-06:59. Empty period columns are dropped downstream, so it shows whatever periods have data. Scoped to the combined-report path; the older per-session single report still uses the fixed Evening/Nighttime layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 51 ++++++++++++---- templates/partials/projects/session_list.html | 61 ++++++++++++++++--- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 4c94f2b..498c68c 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -2241,7 +2241,10 @@ async def delete_session( }) -VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"} +# full_24h = a single continuous 24-hour period (day + night). Operator-set +# only; never auto-derived. In reports its rows are split across +# Daytime/Evening/Nighttime by hour rather than filtered to one window. +VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night", "full_24h"} def _derive_period_type(dt: datetime) -> str: @@ -2255,7 +2258,7 @@ def _derive_period_type(dt: datetime) -> str: def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str: day_abbr = dt.strftime("%a") date_str = f"{dt.month}/{dt.day}" - period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") + period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night", "full_24h": "24-Hour"}.get(period_type, "") parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p] return " — ".join(parts) @@ -4085,7 +4088,15 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids # Prefer per-session period_start/end_hour; fall back to hardcoded defaults. sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night - if sh is None or eh is None: + + target_date = None + if period_type == 'full_24h': + # 24-hour continuous: keep every row (rows get split across + # Daytime/Evening/Nighttime by hour in the sheet builder). No + # hour-window filtering and no single target date. + is_day_session = False + filtered = [(dt, row) for dt, row in parsed if dt] + elif sh is None or eh is None: # Legacy defaults based on period_type is_day_session = period_type in ('weekday_day', 'weekend_day') sh = 7 if is_day_session else 19 @@ -4093,8 +4104,9 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids else: is_day_session = eh > sh # crosses midnight when end < start - target_date = None - if is_day_session: + if period_type == 'full_24h': + pass # filtered already set above + elif is_day_session: # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date in_window = lambda h: sh <= h < eh if entry.get("report_date"): @@ -4152,7 +4164,8 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids # Rebuild session label using the correct label date if label_dt and entry["loc_name"]: period_str = {"weekday_day": "Day", "weekday_night": "Night", - "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") + "weekend_day": "Day", "weekend_night": "Night", + "full_24h": "24-Hour"}.get(period_type, "") day_abbr = label_dt.strftime("%a") date_label = f"{label_dt.month}/{label_dt.day}" session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p) @@ -4411,21 +4424,35 @@ async def generate_combined_from_preview( evening_rows_data = [] night_rows_data = [] + def _row_hour(time_v): + if time_v and ':' in str(time_v): + try: + return int(str(time_v).split(':')[0]) + except ValueError: + pass + return 0 + for pt, time_v, lmx, l1, l2 in parsed_rows: if pt in PERIOD_TYPE_IS_DAY: day_rows_data.append((lmx, l1, l2)) elif pt in PERIOD_TYPE_IS_NIGHT: # Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59 - hour = 0 - if time_v and ':' in str(time_v): - try: - hour = int(str(time_v).split(':')[0]) - except ValueError: - pass + hour = _row_hour(time_v) if 19 <= hour <= 21: evening_rows_data.append((lmx, l1, l2)) else: night_rows_data.append((lmx, l1, l2)) + elif pt == 'full_24h': + # 24-hour continuous: split each row by hour into the three + # non-overlapping buckets (Daytime 7–18:59, Evening 19–21:59, + # Nighttime 22:00–06:59). Empty buckets are dropped downstream. + hour = _row_hour(time_v) + if 7 <= hour < 19: + day_rows_data.append((lmx, l1, l2)) + elif 19 <= hour <= 21: + evening_rows_data.append((lmx, l1, l2)) + else: + night_rows_data.append((lmx, l1, l2)) else: day_rows_data.append((lmx, l1, l2)) diff --git a/templates/partials/projects/session_list.html b/templates/partials/projects/session_list.html index 2886855..1b44bfc 100644 --- a/templates/partials/projects/session_list.html +++ b/templates/partials/projects/session_list.html @@ -13,12 +13,14 @@ 'weekday_night': 'Weekday Night', 'weekend_day': 'Weekend Day', 'weekend_night': 'Weekend Night', + 'full_24h': '24-Hour', } %} {% set period_colors = { 'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', 'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', 'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', 'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'full_24h': 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300', } %}
{% endfor %} +
@@ -229,14 +238,17 @@ const PERIOD_COLORS = { weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + full_24h: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300', }; const PERIOD_LABELS = { weekday_day: 'Weekday Day', weekday_night: 'Weekday Night', weekend_day: 'Weekend Day', weekend_night: 'Weekend Night', + full_24h: '24-Hour', }; -// Default hours for each period type +// Default hours for each period type. full_24h has no window (the report splits +// its rows by hour), so its hour inputs are cleared + disabled in the editor. const PERIOD_DEFAULT_HOURS = { weekday_day: {start: 7, end: 19}, weekday_night: {start: 19, end: 7}, @@ -260,6 +272,20 @@ function openPeriodEditor(sessionId) { if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden'); }); document.getElementById('period-editor-' + sessionId).classList.toggle('hidden'); + + // Reflect the current period type's hour-input state on open (24-Hour + // has no window, so its hour inputs open disabled). + const cur = document.getElementById('period-badge-' + sessionId)?.dataset?.currentPeriod; + const sh = document.getElementById('period-start-hr-' + sessionId); + const eh = document.getElementById('period-end-hr-' + sessionId); + const disable = cur === 'full_24h'; + [sh, eh].forEach(el => { + if (!el) return; + el.disabled = disable; + el.classList.toggle('opacity-50', disable); + el.classList.toggle('cursor-not-allowed', disable); + if (disable) el.placeholder = 'n/a'; + }); } function closePeriodEditor(sessionId) { @@ -281,13 +307,31 @@ function selectPeriodType(sessionId, pt) { btn.classList.toggle('text-gray-600', !isSelected); btn.classList.toggle('dark:text-gray-400', !isSelected); }); - // Fill default hours - const defaults = PERIOD_DEFAULT_HOURS[pt]; - if (defaults) { - const sh = document.getElementById('period-start-hr-' + sessionId); - const eh = document.getElementById('period-end-hr-' + sessionId); - if (sh && !sh.value) sh.value = defaults.start; - if (eh && !eh.value) eh.value = defaults.end; + // Hour inputs: full_24h has no window — clear + disable them. Other types + // re-enable and fill their default window if empty. + const sh = document.getElementById('period-start-hr-' + sessionId); + const eh = document.getElementById('period-end-hr-' + sessionId); + if (pt === 'full_24h') { + [sh, eh].forEach(el => { + if (!el) return; + el.value = ''; + el.disabled = true; + el.classList.add('opacity-50', 'cursor-not-allowed'); + el.placeholder = 'n/a'; + }); + } else { + const defaults = PERIOD_DEFAULT_HOURS[pt]; + [sh, eh].forEach(el => { + if (!el) return; + el.disabled = false; + el.classList.remove('opacity-50', 'cursor-not-allowed'); + }); + if (sh) sh.placeholder = 'e.g. 19'; + if (eh) eh.placeholder = 'e.g. 7'; + if (defaults) { + if (sh && !sh.value) sh.value = defaults.start; + if (eh && !eh.value) eh.value = defaults.end; + } } } @@ -315,6 +359,7 @@ async function savePeriodEditor(sessionId) { const badge = document.getElementById('period-badge-' + sessionId); const label = document.getElementById('period-label-' + sessionId); const newPt = result.period_type; + badge.dataset.currentPeriod = newPt || ''; ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c)); if (newPt && PERIOD_COLORS[newPt]) { badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));