feat: add 24-Hour (full-day) session period type
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) <noreply@anthropic.com>
This commit is contained in:
+39
-12
@@ -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:
|
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:
|
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||||
day_abbr = dt.strftime("%a")
|
day_abbr = dt.strftime("%a")
|
||||||
date_str = f"{dt.month}/{dt.day}"
|
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]
|
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||||
return " — ".join(parts)
|
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.
|
# 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
|
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
|
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
|
# Legacy defaults based on period_type
|
||||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||||
sh = 7 if is_day_session else 19
|
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:
|
else:
|
||||||
is_day_session = eh > sh # crosses midnight when end < start
|
is_day_session = eh > sh # crosses midnight when end < start
|
||||||
|
|
||||||
target_date = None
|
if period_type == 'full_24h':
|
||||||
if is_day_session:
|
pass # filtered already set above
|
||||||
|
elif is_day_session:
|
||||||
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
||||||
in_window = lambda h: sh <= h < eh
|
in_window = lambda h: sh <= h < eh
|
||||||
if entry.get("report_date"):
|
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
|
# Rebuild session label using the correct label date
|
||||||
if label_dt and entry["loc_name"]:
|
if label_dt and entry["loc_name"]:
|
||||||
period_str = {"weekday_day": "Day", "weekday_night": "Night",
|
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")
|
day_abbr = label_dt.strftime("%a")
|
||||||
date_label = f"{label_dt.month}/{label_dt.day}"
|
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)
|
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 = []
|
evening_rows_data = []
|
||||||
night_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:
|
for pt, time_v, lmx, l1, l2 in parsed_rows:
|
||||||
if pt in PERIOD_TYPE_IS_DAY:
|
if pt in PERIOD_TYPE_IS_DAY:
|
||||||
day_rows_data.append((lmx, l1, l2))
|
day_rows_data.append((lmx, l1, l2))
|
||||||
elif pt in PERIOD_TYPE_IS_NIGHT:
|
elif pt in PERIOD_TYPE_IS_NIGHT:
|
||||||
# Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
|
# Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
|
||||||
hour = 0
|
hour = _row_hour(time_v)
|
||||||
if time_v and ':' in str(time_v):
|
|
||||||
try:
|
|
||||||
hour = int(str(time_v).split(':')[0])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if 19 <= hour <= 21:
|
if 19 <= hour <= 21:
|
||||||
evening_rows_data.append((lmx, l1, l2))
|
evening_rows_data.append((lmx, l1, l2))
|
||||||
else:
|
else:
|
||||||
night_rows_data.append((lmx, l1, l2))
|
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:
|
else:
|
||||||
day_rows_data.append((lmx, l1, l2))
|
day_rows_data.append((lmx, l1, l2))
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
'weekday_night': 'Weekday Night',
|
'weekday_night': 'Weekday Night',
|
||||||
'weekend_day': 'Weekend Day',
|
'weekend_day': 'Weekend Day',
|
||||||
'weekend_night': 'Weekend Night',
|
'weekend_night': 'Weekend Night',
|
||||||
|
'full_24h': '24-Hour',
|
||||||
} %}
|
} %}
|
||||||
{% set period_colors = {
|
{% set period_colors = {
|
||||||
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
'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',
|
'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_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',
|
'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',
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<div class="relative" id="period-wrap-{{ s.id }}">
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
<button onclick="openPeriodEditor('{{ s.id }}')"
|
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||||
id="period-badge-{{ s.id }}"
|
id="period-badge-{{ s.id }}"
|
||||||
|
data-current-period="{{ s.period_type or '' }}"
|
||||||
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
title="Click to edit period type and hours">
|
title="Click to edit period type and hours">
|
||||||
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
@@ -78,6 +81,12 @@
|
|||||||
{{ pt_label }}
|
{{ pt_label }}
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<button onclick="selectPeriodType('{{ s.id }}', 'full_24h')"
|
||||||
|
id="pt-btn-{{ s.id }}-full_24h"
|
||||||
|
class="period-type-btn col-span-2 text-xs py-1 px-2 rounded border transition-colors
|
||||||
|
{% if s.period_type == 'full_24h' %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||||
|
24-Hour
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,14 +238,17 @@ const PERIOD_COLORS = {
|
|||||||
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-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_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',
|
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 = {
|
const PERIOD_LABELS = {
|
||||||
weekday_day: 'Weekday Day',
|
weekday_day: 'Weekday Day',
|
||||||
weekday_night: 'Weekday Night',
|
weekday_night: 'Weekday Night',
|
||||||
weekend_day: 'Weekend Day',
|
weekend_day: 'Weekend Day',
|
||||||
weekend_night: 'Weekend Night',
|
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 = {
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
weekday_day: {start: 7, end: 19},
|
weekday_day: {start: 7, end: 19},
|
||||||
weekday_night: {start: 19, end: 7},
|
weekday_night: {start: 19, end: 7},
|
||||||
@@ -260,6 +272,20 @@ function openPeriodEditor(sessionId) {
|
|||||||
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||||
});
|
});
|
||||||
document.getElementById('period-editor-' + sessionId).classList.toggle('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) {
|
function closePeriodEditor(sessionId) {
|
||||||
@@ -281,15 +307,33 @@ function selectPeriodType(sessionId, pt) {
|
|||||||
btn.classList.toggle('text-gray-600', !isSelected);
|
btn.classList.toggle('text-gray-600', !isSelected);
|
||||||
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||||
});
|
});
|
||||||
// Fill default hours
|
// Hour inputs: full_24h has no window — clear + disable them. Other types
|
||||||
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
// re-enable and fill their default window if empty.
|
||||||
if (defaults) {
|
|
||||||
const sh = document.getElementById('period-start-hr-' + sessionId);
|
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||||
const eh = document.getElementById('period-end-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 (sh && !sh.value) sh.value = defaults.start;
|
||||||
if (eh && !eh.value) eh.value = defaults.end;
|
if (eh && !eh.value) eh.value = defaults.end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function savePeriodEditor(sessionId) {
|
async function savePeriodEditor(sessionId) {
|
||||||
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
||||||
@@ -315,6 +359,7 @@ async function savePeriodEditor(sessionId) {
|
|||||||
const badge = document.getElementById('period-badge-' + sessionId);
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
const label = document.getElementById('period-label-' + sessionId);
|
const label = document.getElementById('period-label-' + sessionId);
|
||||||
const newPt = result.period_type;
|
const newPt = result.period_type;
|
||||||
|
badge.dataset.currentPeriod = newPt || '';
|
||||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
if (newPt && PERIOD_COLORS[newPt]) {
|
if (newPt && PERIOD_COLORS[newPt]) {
|
||||||
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||||
|
|||||||
Reference in New Issue
Block a user