feat: v4.1 - Czech diacritics in PDF (Liberation fonts), hour/min duration fields, day select in modal
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl \
|
&& apt-get install -y --no-install-recommends curl fonts-liberation \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
VERSION = "4.0.0"
|
VERSION = "4.1.0"
|
||||||
MAX_FILE_SIZE_MB = 10
|
MAX_FILE_SIZE_MB = 10
|
||||||
DEFAULT_COLOR = "#ffffff"
|
DEFAULT_COLOR = "#ffffff"
|
||||||
|
|||||||
@@ -7,15 +7,62 @@ Always exactly one page, A4 landscape.
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from reportlab.lib.pagesizes import A4, landscape
|
from reportlab.lib.pagesizes import A4, landscape
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.pdfgen import canvas as rl_canvas
|
from reportlab.pdfgen import canvas as rl_canvas
|
||||||
import logging
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
|
||||||
from .validator import ScenarsError
|
from .validator import ScenarsError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Font registration ─────────────────────────────────────────────────────────
|
||||||
|
# LiberationSans supports Czech diacritics. Fallback to Helvetica if not found.
|
||||||
|
_FONT_REGULAR = 'Helvetica'
|
||||||
|
_FONT_BOLD = 'Helvetica-Bold'
|
||||||
|
_FONT_ITALIC = 'Helvetica-Oblique'
|
||||||
|
|
||||||
|
_LIBERATION_PATHS = [
|
||||||
|
'/usr/share/fonts/truetype/liberation',
|
||||||
|
'/usr/share/fonts/liberation',
|
||||||
|
'/usr/share/fonts/truetype',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_font(filename: str):
|
||||||
|
for base in _LIBERATION_PATHS:
|
||||||
|
path = os.path.join(base, filename)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _register_fonts():
|
||||||
|
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
|
||||||
|
regular = _find_font('LiberationSans-Regular.ttf')
|
||||||
|
bold = _find_font('LiberationSans-Bold.ttf')
|
||||||
|
italic = _find_font('LiberationSans-Italic.ttf')
|
||||||
|
if regular and bold and italic:
|
||||||
|
try:
|
||||||
|
pdfmetrics.registerFont(TTFont('LiberationSans', regular))
|
||||||
|
pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold))
|
||||||
|
pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic))
|
||||||
|
_FONT_REGULAR = 'LiberationSans'
|
||||||
|
_FONT_BOLD = 'LiberationSans-Bold'
|
||||||
|
_FONT_ITALIC = 'LiberationSans-Italic'
|
||||||
|
logger.info('PDF: Using LiberationSans (Czech diacritics supported)')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'PDF: Font registration failed: {e}')
|
||||||
|
else:
|
||||||
|
logger.warning('PDF: LiberationSans not found, Czech diacritics may be broken')
|
||||||
|
|
||||||
|
|
||||||
|
_register_fonts()
|
||||||
|
|
||||||
PAGE_W, PAGE_H = landscape(A4)
|
PAGE_W, PAGE_H = landscape(A4)
|
||||||
MARGIN = 10 * mm
|
MARGIN = 10 * mm
|
||||||
|
|
||||||
@@ -186,13 +233,13 @@ def generate_pdf(doc) -> bytes:
|
|||||||
|
|
||||||
# ── Draw header ───────────────────────────────────────────────────
|
# ── Draw header ───────────────────────────────────────────────────
|
||||||
y = y_top
|
y = y_top
|
||||||
c.setFont('Helvetica-Bold', TITLE_SIZE)
|
c.setFont(_FONT_BOLD, TITLE_SIZE)
|
||||||
set_fill(c, HEADER_BG)
|
set_fill(c, HEADER_BG)
|
||||||
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
|
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
|
||||||
y -= TITLE_SIZE + 5
|
y -= TITLE_SIZE + 5
|
||||||
|
|
||||||
if doc.event.subtitle:
|
if doc.event.subtitle:
|
||||||
c.setFont('Helvetica', SUB_SIZE)
|
c.setFont(_FONT_REGULAR, SUB_SIZE)
|
||||||
set_fill(c, (0.4, 0.4, 0.4))
|
set_fill(c, (0.4, 0.4, 0.4))
|
||||||
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
|
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
|
||||||
y -= SUB_SIZE + 3
|
y -= SUB_SIZE + 3
|
||||||
@@ -208,7 +255,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
parts.append(f'Datum: {date_display}')
|
parts.append(f'Datum: {date_display}')
|
||||||
if doc.event.location:
|
if doc.event.location:
|
||||||
parts.append(f'Místo: {doc.event.location}')
|
parts.append(f'Místo: {doc.event.location}')
|
||||||
c.setFont('Helvetica', INFO_SIZE)
|
c.setFont(_FONT_REGULAR, INFO_SIZE)
|
||||||
set_fill(c, (0.5, 0.5, 0.5))
|
set_fill(c, (0.5, 0.5, 0.5))
|
||||||
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
|
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
|
||||||
y -= INFO_SIZE + 3
|
y -= INFO_SIZE + 3
|
||||||
@@ -230,7 +277,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
c.line(tx, table_top, tx, table_top + TIME_AXIS_H)
|
c.line(tx, table_top, tx, table_top + TIME_AXIS_H)
|
||||||
# label
|
# label
|
||||||
label = fmt_time(m)
|
label = fmt_time(m)
|
||||||
c.setFont('Helvetica', time_axis_font)
|
c.setFont(_FONT_REGULAR, time_axis_font)
|
||||||
set_fill(c, AXIS_TEXT)
|
set_fill(c, AXIS_TEXT)
|
||||||
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label)
|
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label)
|
||||||
|
|
||||||
@@ -244,7 +291,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
for m in range(t_start, t_end + 1, 60):
|
for m in range(t_start, t_end + 1, 60):
|
||||||
slot_idx = (m - t_start) // 15
|
slot_idx = (m - t_start) // 15
|
||||||
tx = x0 + DATE_COL_W + slot_idx * slot_w
|
tx = x0 + DATE_COL_W + slot_idx * slot_w
|
||||||
c.setFont('Helvetica', time_axis_font)
|
c.setFont(_FONT_REGULAR, time_axis_font)
|
||||||
set_fill(c, AXIS_TEXT)
|
set_fill(c, AXIS_TEXT)
|
||||||
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m))
|
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m))
|
||||||
|
|
||||||
@@ -263,7 +310,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
# Date label cell
|
# Date label cell
|
||||||
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
|
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
|
||||||
draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h,
|
draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h,
|
||||||
'Helvetica-Bold', date_font, AXIS_TEXT, 'center')
|
_FONT_BOLD, date_font, AXIS_TEXT, 'center')
|
||||||
|
|
||||||
# Vertical grid lines (15-min slots, hour lines darker)
|
# Vertical grid lines (15-min slots, hour lines darker)
|
||||||
for slot_i in range(num_15min_slots + 1):
|
for slot_i in range(num_15min_slots + 1):
|
||||||
@@ -315,15 +362,15 @@ def generate_pdf(doc) -> bytes:
|
|||||||
title_line = block.title + (' →' if overnight else '')
|
title_line = block.title + (' →' if overnight else '')
|
||||||
if row_h > block_title_font * 2.2 and block.responsible:
|
if row_h > block_title_font * 2.2 and block.responsible:
|
||||||
# Two lines: title + responsible
|
# Two lines: title + responsible
|
||||||
c.setFont('Helvetica-Bold', block_title_font)
|
c.setFont(_FONT_BOLD, block_title_font)
|
||||||
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line)
|
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line)
|
||||||
c.setFont('Helvetica', block_time_font)
|
c.setFont(_FONT_REGULAR, block_time_font)
|
||||||
set_fill(c, (text_rgb[0] * 0.8, text_rgb[1] * 0.8, text_rgb[2] * 0.8)
|
set_fill(c, (text_rgb[0] * 0.8, text_rgb[1] * 0.8, text_rgb[2] * 0.8)
|
||||||
if is_light(pt.color) else (0.85, 0.85, 0.85))
|
if is_light(pt.color) else (0.85, 0.85, 0.85))
|
||||||
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1,
|
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1,
|
||||||
block.responsible)
|
block.responsible)
|
||||||
else:
|
else:
|
||||||
c.setFont('Helvetica-Bold', block_title_font)
|
c.setFont(_FONT_BOLD, block_title_font)
|
||||||
c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line)
|
c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line)
|
||||||
|
|
||||||
c.restoreState()
|
c.restoreState()
|
||||||
@@ -331,7 +378,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
# ── Legend ────────────────────────────────────────────────────────
|
# ── Legend ────────────────────────────────────────────────────────
|
||||||
legend_y_top = table_top - num_days * row_h - 6
|
legend_y_top = table_top - num_days * row_h - 6
|
||||||
|
|
||||||
c.setFont('Helvetica-Bold', 7)
|
c.setFont(_FONT_BOLD, 7)
|
||||||
set_fill(c, HEADER_BG)
|
set_fill(c, HEADER_BG)
|
||||||
c.drawString(x0, legend_y_top, 'Legenda:')
|
c.drawString(x0, legend_y_top, 'Legenda:')
|
||||||
legend_y_top -= LEGEND_ITEM_H
|
legend_y_top -= LEGEND_ITEM_H
|
||||||
@@ -349,7 +396,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
fill_rgb, BORDER, 0.3)
|
fill_rgb, BORDER, 0.3)
|
||||||
|
|
||||||
# Type name NEXT TO the square
|
# Type name NEXT TO the square
|
||||||
c.setFont('Helvetica', 7)
|
c.setFont(_FONT_REGULAR, 7)
|
||||||
set_fill(c, (0.15, 0.15, 0.15))
|
set_fill(c, (0.15, 0.15, 0.15))
|
||||||
c.drawString(lx + LEGEND_BOX_W + 3,
|
c.drawString(lx + LEGEND_BOX_W + 3,
|
||||||
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
|
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
|
||||||
@@ -357,7 +404,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
|
|
||||||
# ── Footer ────────────────────────────────────────────────────────
|
# ── Footer ────────────────────────────────────────────────────────
|
||||||
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
|
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
|
||||||
c.setFont('Helvetica-Oblique', 6.5)
|
c.setFont(_FONT_ITALIC, 6.5)
|
||||||
set_fill(c, FOOTER_TEXT)
|
set_fill(c, FOOTER_TEXT)
|
||||||
c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}')
|
c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}')
|
||||||
|
|
||||||
|
|||||||
@@ -875,3 +875,40 @@ body {
|
|||||||
.block-el:hover::after {
|
.block-el:hover::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Duration row (hours + minutes) */
|
||||||
|
.duration-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-field input[type="number"] {
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-field input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Den</label>
|
<label>Den</label>
|
||||||
<input type="date" id="modalBlockDate">
|
<select id="modalBlockDate"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Typ programu</label>
|
<label>Typ programu</label>
|
||||||
@@ -177,16 +177,25 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Začátek</label>
|
<label>Začátek</label>
|
||||||
<input type="time" id="modalBlockStart">
|
<input type="time" id="modalBlockStart" step="900">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Konec</label>
|
<label>Konec</label>
|
||||||
<input type="time" id="modalBlockEnd">
|
<input type="time" id="modalBlockEnd" step="900">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Nebo trvání</label>
|
<label>Nebo trvání</label>
|
||||||
<input type="text" id="modalBlockDuration" placeholder="HH:MM (např. 1:30)">
|
<div class="duration-row">
|
||||||
|
<div class="duration-field">
|
||||||
|
<input type="number" id="modalDurHours" min="0" max="23" placeholder="0">
|
||||||
|
<span class="duration-unit">hod</span>
|
||||||
|
</div>
|
||||||
|
<div class="duration-field">
|
||||||
|
<input type="number" id="modalDurMinutes" min="0" max="59" step="15" placeholder="0">
|
||||||
|
<span class="duration-unit">min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Garant</label>
|
<label>Garant</label>
|
||||||
|
|||||||
@@ -173,10 +173,11 @@ const App = {
|
|||||||
document.getElementById('modalBlockEnd').value = block.end || '';
|
document.getElementById('modalBlockEnd').value = block.end || '';
|
||||||
document.getElementById('modalBlockResponsible').value = block.responsible || '';
|
document.getElementById('modalBlockResponsible').value = block.responsible || '';
|
||||||
document.getElementById('modalBlockNotes').value = block.notes || '';
|
document.getElementById('modalBlockNotes').value = block.notes || '';
|
||||||
this._updateDuration();
|
|
||||||
|
|
||||||
this._populateTypeSelect(block.type_id);
|
this._populateTypeSelect(block.type_id);
|
||||||
document.getElementById('modalBlockDate').value = block.date || '';
|
this._populateDaySelect(block.date);
|
||||||
|
this._updateDuration();
|
||||||
|
|
||||||
document.getElementById('modalDeleteBtn').style.display = 'inline-block';
|
document.getElementById('modalDeleteBtn').style.display = 'inline-block';
|
||||||
document.getElementById('blockModal').classList.remove('hidden');
|
document.getElementById('blockModal').classList.remove('hidden');
|
||||||
},
|
},
|
||||||
@@ -190,10 +191,11 @@ const App = {
|
|||||||
document.getElementById('modalBlockEnd').value = end || '10:00';
|
document.getElementById('modalBlockEnd').value = end || '10:00';
|
||||||
document.getElementById('modalBlockResponsible').value = '';
|
document.getElementById('modalBlockResponsible').value = '';
|
||||||
document.getElementById('modalBlockNotes').value = '';
|
document.getElementById('modalBlockNotes').value = '';
|
||||||
document.getElementById('modalBlockDate').value = date || '';
|
|
||||||
this._updateDuration();
|
|
||||||
|
|
||||||
this._populateTypeSelect(null);
|
this._populateTypeSelect(null);
|
||||||
|
this._populateDaySelect(date);
|
||||||
|
this._updateDuration();
|
||||||
|
|
||||||
document.getElementById('modalDeleteBtn').style.display = 'none';
|
document.getElementById('modalDeleteBtn').style.display = 'none';
|
||||||
document.getElementById('blockModal').classList.remove('hidden');
|
document.getElementById('blockModal').classList.remove('hidden');
|
||||||
},
|
},
|
||||||
@@ -210,18 +212,59 @@ const App = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_populateDaySelect(selectedDate) {
|
||||||
|
const sel = document.getElementById('modalBlockDate');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
const dates = this.getDates();
|
||||||
|
if (dates.length === 0) {
|
||||||
|
// Fallback: show today
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = today;
|
||||||
|
opt.textContent = this._formatDateLabel(today);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dates.forEach(date => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = date;
|
||||||
|
opt.textContent = this._formatDateLabel(date);
|
||||||
|
if (date === selectedDate) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
// If none selected, default to first
|
||||||
|
if (!selectedDate || !dates.includes(selectedDate)) {
|
||||||
|
sel.value = dates[0];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatDateLabel(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
|
||||||
|
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
|
||||||
|
return `${weekdayCap} (${d.getDate()}.${d.getMonth() + 1})`;
|
||||||
|
},
|
||||||
|
|
||||||
_updateDuration() {
|
_updateDuration() {
|
||||||
const startVal = document.getElementById('modalBlockStart').value;
|
const startVal = document.getElementById('modalBlockStart').value;
|
||||||
const endVal = document.getElementById('modalBlockEnd').value;
|
const endVal = document.getElementById('modalBlockEnd').value;
|
||||||
if (!startVal || !endVal) {
|
if (!startVal || !endVal) {
|
||||||
document.getElementById('modalBlockDuration').value = '';
|
document.getElementById('modalDurHours').value = '';
|
||||||
|
document.getElementById('modalDurMinutes').value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const s = this.parseTimeToMin(startVal);
|
const s = this.parseTimeToMin(startVal);
|
||||||
let e = this.parseTimeToMin(endVal);
|
let e = this.parseTimeToMin(endVal);
|
||||||
if (e < s) e += 24 * 60; // overnight
|
if (e <= s) e += 24 * 60; // overnight
|
||||||
const dur = e - s;
|
const dur = e - s;
|
||||||
document.getElementById('modalBlockDuration').value = this.minutesToTime(dur);
|
document.getElementById('modalDurHours').value = Math.floor(dur / 60);
|
||||||
|
document.getElementById('modalDurMinutes').value = dur % 60;
|
||||||
|
},
|
||||||
|
|
||||||
|
_getDurationMinutes() {
|
||||||
|
const h = parseInt(document.getElementById('modalDurHours').value) || 0;
|
||||||
|
const m = parseInt(document.getElementById('modalDurMinutes').value) || 0;
|
||||||
|
return h * 60 + m;
|
||||||
},
|
},
|
||||||
|
|
||||||
_saveModal() {
|
_saveModal() {
|
||||||
@@ -334,19 +377,21 @@ const App = {
|
|||||||
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
|
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
|
||||||
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
|
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
|
||||||
|
|
||||||
// Duration ↔ end sync
|
// Duration ↔ end sync (hours + minutes fields)
|
||||||
document.getElementById('modalBlockDuration').addEventListener('input', (e) => {
|
const durUpdater = () => {
|
||||||
const durStr = e.target.value.trim();
|
|
||||||
const startVal = document.getElementById('modalBlockStart').value;
|
const startVal = document.getElementById('modalBlockStart').value;
|
||||||
if (!startVal || !durStr) return;
|
const durMin = this._getDurationMinutes();
|
||||||
const durParts = durStr.split(':').map(Number);
|
if (!startVal || durMin <= 0) return;
|
||||||
if (durParts.length < 2 || isNaN(durParts[0]) || isNaN(durParts[1])) return;
|
|
||||||
const durMin = durParts[0] * 60 + durParts[1];
|
|
||||||
if (durMin <= 0) return;
|
|
||||||
const startMin = this.parseTimeToMin(startVal);
|
const startMin = this.parseTimeToMin(startVal);
|
||||||
const endMin = startMin + durMin;
|
const endMin = startMin + durMin;
|
||||||
document.getElementById('modalBlockEnd').value = this.minutesToTime(endMin % 1440 || endMin);
|
// Allow overnight (> 24h notation handled as raw minutes)
|
||||||
});
|
const h = Math.floor(endMin / 60) % 24;
|
||||||
|
const m = endMin % 60;
|
||||||
|
document.getElementById('modalBlockEnd').value =
|
||||||
|
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
document.getElementById('modalDurHours').addEventListener('input', durUpdater);
|
||||||
|
document.getElementById('modalDurMinutes').addEventListener('input', durUpdater);
|
||||||
|
|
||||||
document.getElementById('modalBlockEnd').addEventListener('input', () => {
|
document.getElementById('modalBlockEnd').addEventListener('input', () => {
|
||||||
this._updateDuration();
|
this._updateDuration();
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def test_health(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
assert data["version"] == "4.0.0"
|
assert data["version"] == "4.1.0"
|
||||||
|
|
||||||
|
|
||||||
def test_root_returns_html(client):
|
def test_root_returns_html(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user