diff --git a/app/api/scenario.py b/app/api/scenario.py index 915078d..58a82e7 100644 --- a/app/api/scenario.py +++ b/app/api/scenario.py @@ -33,8 +33,9 @@ async def validate_scenario(doc: ScenarioDocument): for i, block in enumerate(doc.blocks): if block.type_id not in type_ids: errors.append(f"Block {i+1}: unknown type '{block.type_id}'") - if block.start >= block.end: - errors.append(f"Block {i+1}: start time must be before end time") + if block.start == block.end: + errors.append(f"Block {i+1}: start time must differ from end time") + # Note: end < start is allowed for overnight blocks (block crosses midnight) return ValidationResponse(valid=len(errors) == 0, errors=errors) diff --git a/app/config.py b/app/config.py index 2966b6e..e7a23fa 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ """Application configuration.""" -VERSION = "3.0.0" +VERSION = "4.0.0" MAX_FILE_SIZE_MB = 10 DEFAULT_COLOR = "#ffffff" diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index e581024..17bb37b 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -1,6 +1,7 @@ """ -PDF generation for Scenar Creator v3 using ReportLab Canvas API. -Generates A4 landscape timetable — always exactly one page. +PDF generation for Scenar Creator v4 using ReportLab Canvas API. +Layout: rows = days, columns = time slots (15 min). +Always exactly one page, A4 landscape. """ from io import BytesIO @@ -18,25 +19,25 @@ logger = logging.getLogger(__name__) PAGE_W, PAGE_H = landscape(A4) MARGIN = 10 * mm -# Color palette for UI +# Colors HEADER_BG = (0.118, 0.161, 0.231) # dark navy -GRID_LINE = (0.85, 0.85, 0.85) HEADER_TEXT = (1.0, 1.0, 1.0) -BODY_ALT = (0.97, 0.97, 0.97) +AXIS_BG = (0.96, 0.96, 0.97) +AXIS_TEXT = (0.45, 0.45, 0.45) +GRID_HOUR = (0.78, 0.78, 0.82) +GRID_15MIN = (0.90, 0.90, 0.93) +ALT_ROW = (0.975, 0.975, 0.98) FOOTER_TEXT = (0.6, 0.6, 0.6) -LABEL_TEXT = (0.35, 0.35, 0.35) +BORDER = (0.82, 0.82, 0.86) def hex_to_rgb(hex_color: str) -> tuple: - h = hex_color.lstrip('#') + h = (hex_color or '#888888').lstrip('#') if len(h) == 8: h = h[2:] if len(h) != 6: - return (0.8, 0.8, 0.8) - r = int(h[0:2], 16) / 255.0 - g = int(h[2:4], 16) / 255.0 - b = int(h[4:6], 16) / 255.0 - return (r, g, b) + return (0.7, 0.7, 0.7) + return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0) def is_light(hex_color: str) -> bool: @@ -44,13 +45,14 @@ def is_light(hex_color: str) -> bool: return (0.299 * r + 0.587 * g + 0.114 * b) > 0.6 -def time_to_minutes(s: str) -> int: - h, m = s.split(":") - return int(h) * 60 + int(m) +def time_to_min(s: str) -> int: + parts = s.split(':') + return int(parts[0]) * 60 + int(parts[1]) def fmt_time(total_minutes: int) -> str: - return f"{total_minutes // 60:02d}:{total_minutes % 60:02d}" + norm = total_minutes % 1440 + return f"{norm // 60:02d}:{norm % 60:02d}" def set_fill(c, rgb): @@ -61,33 +63,32 @@ def set_stroke(c, rgb): c.setStrokeColorRGB(*rgb) -def draw_rect_filled(c, x, y, w, h, fill_rgb, stroke_rgb=None, stroke_width=0.5): - set_fill(c, fill_rgb) - if stroke_rgb: - set_stroke(c, stroke_rgb) - c.setLineWidth(stroke_width) +def fill_rect(c, x, y, w, h, fill, stroke=None, sw=0.4): + set_fill(c, fill) + if stroke: + set_stroke(c, stroke) + c.setLineWidth(sw) c.rect(x, y, w, h, fill=1, stroke=1) else: c.rect(x, y, w, h, fill=1, stroke=0) -def draw_text_clipped(c, text, x, y, w, h, font, size, rgb, align="center"): - """Draw text centered/left in a bounding box, clipped to width.""" +def draw_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'): + if not text: + return c.saveState() - c.rect(x, y, w, h, fill=0, stroke=0) - c.clipPath(c.beginPath(), stroke=0, fill=0) - # Use a proper clip path p = c.beginPath() - p.rect(x, y, w, h) + p.rect(x + 1, y, w - 2, h) c.clipPath(p, stroke=0, fill=0) set_fill(c, rgb) c.setFont(font, size) - if align == "center": - c.drawCentredString(x + w / 2, y + (h - size) / 2 + 1, text) - elif align == "right": - c.drawRightString(x + w - 2, y + (h - size) / 2 + 1, text) + ty = y + (h - size) / 2 + if align == 'center': + c.drawCentredString(x + w / 2, ty, text) + elif align == 'right': + c.drawRightString(x + w - 2, ty, text) else: - c.drawString(x + 2, y + (h - size) / 2 + 1, text) + c.drawString(x + 2, ty, text) c.restoreState() @@ -100,218 +101,251 @@ def generate_pdf(doc) -> bytes: if block.type_id not in type_map: raise ScenarsError(f"Missing type definition: '{block.type_id}'") - # Group by date - blocks_by_date = defaultdict(list) - for b in doc.blocks: - blocks_by_date[b.date].append(b) - sorted_dates = sorted(blocks_by_date.keys()) + # Collect dates + time range + sorted_dates = doc.get_sorted_dates() + num_days = len(sorted_dates) - # Time range (round to 30-min boundaries) - all_starts = [time_to_minutes(b.start) for b in doc.blocks] - all_ends = [time_to_minutes(b.end) for b in doc.blocks] - t_start = (min(all_starts) // 30) * 30 - t_end = ((max(all_ends) + 29) // 30) * 30 - slot_minutes = 30 - num_slots = (t_end - t_start) // slot_minutes + all_starts = [time_to_min(b.start) for b in doc.blocks] + all_ends_raw = [] + for b in doc.blocks: + s = time_to_min(b.start) + e = time_to_min(b.end) + if e <= s: + e += 24 * 60 # overnight: extend past midnight + all_ends_raw.append(e) + + t_start = (min(all_starts) // 60) * 60 + t_end = ((max(all_ends_raw) + 14) // 15) * 15 + # Guard: clamp to reasonable range + t_start = max(0, t_start) + t_end = min(t_start + 24 * 60, t_end) + total_min = t_end - t_start buf = BytesIO() c = rl_canvas.Canvas(buf, pagesize=landscape(A4)) - # ── Layout constants ────────────────────────────────────────────── + # ── Layout ──────────────────────────────────────────────────────── x0 = MARGIN y_top = PAGE_H - MARGIN - # Header block - title_size = 16 - subtitle_size = 10 - info_size = 8 - header_h = title_size + 4 + # Header block (title + subtitle + info) + TITLE_SIZE = 16 + SUB_SIZE = 10 + INFO_SIZE = 8 - has_subtitle = bool(doc.event.subtitle) - has_info = bool(doc.event.date or doc.event.location) - if has_subtitle: - header_h += subtitle_size + 2 + header_h = TITLE_SIZE + 5 + if doc.event.subtitle: + header_h += SUB_SIZE + 3 + has_info = bool(doc.event.date or doc.event.date_from or doc.event.location) if has_info: - header_h += info_size + 2 - header_h += 4 # bottom padding + header_h += INFO_SIZE + 3 + header_h += 4 - # Legend block (one row per type) - legend_item_h = 12 - legend_h = len(doc.program_types) * legend_item_h + 6 + # Legend: one row per type, multi-column + LEGEND_ITEM_H = 12 + LEGEND_BOX_W = 10 * mm + LEGEND_TEXT_W = 48 * mm + LEGEND_STRIDE = LEGEND_BOX_W + LEGEND_TEXT_W + 3 * mm + available_w_for_legend = PAGE_W - 2 * MARGIN + legend_cols = max(1, int(available_w_for_legend / LEGEND_STRIDE)) + legend_rows = (len(doc.program_types) + legend_cols - 1) // legend_cols + LEGEND_H = legend_rows * LEGEND_ITEM_H + LEGEND_ITEM_H + 4 # +label row - # Footer - footer_h = 10 + FOOTER_H = 10 + TIME_AXIS_H = 18 + DATE_COL_W = 18 * mm - # Available height for timetable - avail_h = PAGE_H - 2 * MARGIN - header_h - legend_h - footer_h - 6 - row_h = max(8, avail_h / max(num_slots, 1)) + # Available area for the timetable grid + avail_h = PAGE_H - 2 * MARGIN - header_h - LEGEND_H - FOOTER_H - TIME_AXIS_H - 6 + row_h = max(10, avail_h / max(num_days, 1)) - # Time axis width - time_col_w = 14 * mm - # Column widths for dates - avail_w = PAGE_W - 2 * MARGIN - time_col_w - day_col_w = avail_w / max(len(sorted_dates), 1) + avail_w = PAGE_W - 2 * MARGIN - DATE_COL_W + # 15-min slot width + num_15min_slots = total_min // 15 + slot_w = avail_w / max(num_15min_slots, 1) - # Font sizes scale with row height - cell_font_size = max(5.5, min(8.0, row_h * 0.45)) - time_font_size = max(5.0, min(7.0, row_h * 0.42)) - header_col_size = max(6.0, min(9.0, day_col_w * 0.08)) + # font sizes scale with row/col + date_font = max(5.5, min(8.5, row_h * 0.38)) + block_title_font = max(5.0, min(8.0, min(row_h, slot_w * 4) * 0.38)) + block_time_font = max(4.0, min(6.0, block_title_font - 1.0)) + time_axis_font = max(5.5, min(8.0, slot_w * 3)) # ── Draw header ─────────────────────────────────────────────────── y = y_top - c.setFont("Helvetica-Bold", title_size) + c.setFont('Helvetica-Bold', TITLE_SIZE) set_fill(c, HEADER_BG) - c.drawString(x0, y - title_size, doc.event.title) - y -= title_size + 4 + c.drawString(x0, y - TITLE_SIZE, doc.event.title) + y -= TITLE_SIZE + 5 - if has_subtitle: - c.setFont("Helvetica", subtitle_size) + if doc.event.subtitle: + c.setFont('Helvetica', SUB_SIZE) set_fill(c, (0.4, 0.4, 0.4)) - c.drawString(x0, y - subtitle_size, doc.event.subtitle) - y -= subtitle_size + 2 + c.drawString(x0, y - SUB_SIZE, doc.event.subtitle) + y -= SUB_SIZE + 3 if has_info: parts = [] - if doc.event.date: - parts.append(f"Datum: {doc.event.date}") + date_display = doc.event.date_from or doc.event.date + date_to_display = doc.event.date_to + if date_display: + if date_to_display and date_to_display != date_display: + parts.append(f'Datum: {date_display} – {date_to_display}') + else: + parts.append(f'Datum: {date_display}') if doc.event.location: - parts.append(f"Místo: {doc.event.location}") - c.setFont("Helvetica", info_size) + parts.append(f'Místo: {doc.event.location}') + c.setFont('Helvetica', INFO_SIZE) set_fill(c, (0.5, 0.5, 0.5)) - c.drawString(x0, y - info_size, " | ".join(parts)) - y -= info_size + 2 + c.drawString(x0, y - INFO_SIZE, ' | '.join(parts)) + y -= INFO_SIZE + 3 + y -= 4 # padding - y -= 6 # padding before table + # ── Time axis header ────────────────────────────────────────────── + table_top = y - TIME_AXIS_H + + # Date column header (empty corner) + fill_rect(c, x0, table_top, DATE_COL_W, TIME_AXIS_H, AXIS_BG, BORDER, 0.4) + + # Time labels (only whole hours) + for m in range(t_start, t_end + 1, 60): + slot_idx = (m - t_start) // 15 + tx = x0 + DATE_COL_W + slot_idx * slot_w + # tick line + set_stroke(c, GRID_HOUR) + c.setLineWidth(0.5) + c.line(tx, table_top, tx, table_top + TIME_AXIS_H) + # label + label = fmt_time(m) + c.setFont('Helvetica', time_axis_font) + set_fill(c, AXIS_TEXT) + c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label) + + # Right border of time axis + fill_rect(c, x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, AXIS_BG, BORDER, 0.3) + # Re-draw to not cover tick lines: draw border rectangle only + set_stroke(c, BORDER) + c.setLineWidth(0.4) + c.rect(x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, fill=0, stroke=1) + # Re-draw time labels on top of border rect + for m in range(t_start, t_end + 1, 60): + slot_idx = (m - t_start) // 15 + tx = x0 + DATE_COL_W + slot_idx * slot_w + c.setFont('Helvetica', time_axis_font) + set_fill(c, AXIS_TEXT) + c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m)) + + # ── Draw day rows ───────────────────────────────────────────────── + blocks_by_date = defaultdict(list) + for b in doc.blocks: + blocks_by_date[b.date].append(b) - # ── Draw column headers ─────────────────────────────────────────── - col_header_h = 16 - # Time axis header cell - draw_rect_filled(c, x0, y - col_header_h, time_col_w, col_header_h, HEADER_BG) - # Date header cells for di, date_key in enumerate(sorted_dates): - cx = x0 + time_col_w + di * day_col_w - draw_rect_filled(c, cx, y - col_header_h, day_col_w, col_header_h, HEADER_BG, GRID_LINE) - c.saveState() - p = c.beginPath() - p.rect(cx + 1, y - col_header_h + 1, day_col_w - 2, col_header_h - 2) - c.clipPath(p, stroke=0, fill=0) - set_fill(c, HEADER_TEXT) - c.setFont("Helvetica-Bold", header_col_size) - c.drawCentredString(cx + day_col_w / 2, y - col_header_h + (col_header_h - header_col_size) / 2, date_key) - c.restoreState() + row_y = table_top - (di + 1) * row_h - y -= col_header_h + # Alternating row background + row_bg = (1.0, 1.0, 1.0) if di % 2 == 0 else ALT_ROW + fill_rect(c, x0 + DATE_COL_W, row_y, avail_w, row_h, row_bg, BORDER, 0.3) - # ── Draw time grid ──────────────────────────────────────────────── - table_top = y - table_bottom = y - num_slots * row_h + # Date label cell + fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4) + draw_clipped_text(c, date_key, x0, row_y, DATE_COL_W, row_h, + 'Helvetica-Bold', date_font, AXIS_TEXT, 'center') - for slot_i in range(num_slots): - slot_min = t_start + slot_i * slot_minutes - slot_label = fmt_time(slot_min) - row_y = y - slot_i * row_h + # Vertical grid lines (15-min slots, hour lines darker) + for slot_i in range(num_15min_slots + 1): + min_at_slot = t_start + slot_i * 15 + tx = x0 + DATE_COL_W + slot_i * slot_w + is_hour = (min_at_slot % 60 == 0) + line_col = GRID_HOUR if is_hour else GRID_15MIN + set_stroke(c, line_col) + c.setLineWidth(0.5 if is_hour else 0.25) + c.line(tx, row_y, tx, row_y + row_h) - # Alternating row bg for day columns - alt_bg = BODY_ALT if slot_i % 2 == 1 else (1.0, 1.0, 1.0) - for di in range(len(sorted_dates)): - cx = x0 + time_col_w + di * day_col_w - draw_rect_filled(c, cx, row_y - row_h, day_col_w, row_h, alt_bg, GRID_LINE, 0.3) - - # Time axis cell - draw_rect_filled(c, x0, row_y - row_h, time_col_w, row_h, (0.96, 0.96, 0.96), GRID_LINE, 0.3) - set_fill(c, LABEL_TEXT) - c.setFont("Helvetica", time_font_size) - c.drawRightString(x0 + time_col_w - 2, row_y - row_h + (row_h - time_font_size) / 2, slot_label) - - # ── Draw program blocks ─────────────────────────────────────────── - for di, date_key in enumerate(sorted_dates): - cx = x0 + time_col_w + di * day_col_w + # Draw program blocks for block in blocks_by_date[date_key]: - b_start = time_to_minutes(block.start) - b_end = time_to_minutes(block.end) - if b_start < t_start or b_end > t_end: + s = time_to_min(block.start) + e = time_to_min(block.end) + overnight = e <= s + if overnight: + e_draw = min(t_end, s + (e + 1440 - s)) # cap at t_end + else: + e_draw = e + + cs = max(s, t_start) + ce = min(e_draw, t_end) + if ce <= cs: continue - offset_slots_start = (b_start - t_start) / slot_minutes - offset_slots_end = (b_end - t_start) / slot_minutes - block_top = table_top - offset_slots_start * row_h - block_bot = table_top - offset_slots_end * row_h - block_h = block_top - block_bot + + bx = x0 + DATE_COL_W + (cs - t_start) / 15 * slot_w + bw = (ce - cs) / 15 * slot_w pt = type_map[block.type_id] fill_rgb = hex_to_rgb(pt.color) - text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0) - - # Block rectangle with slight inset - inset = 0.5 - bx = cx + inset - by = block_bot + inset - bw = day_col_w - 2 * inset - bh = block_h - 2 * inset + text_rgb = (0.08, 0.08, 0.08) if is_light(pt.color) else (1.0, 1.0, 1.0) + inset = 1.0 c.saveState() - draw_rect_filled(c, bx, by, bw, bh, fill_rgb, GRID_LINE, 0.5) + # Draw block rectangle + set_fill(c, fill_rgb) + set_stroke(c, (0.0, 0.0, 0.0) if False else fill_rgb) # no border stroke + c.roundRect(bx + inset, row_y + inset, bw - 2 * inset, row_h - 2 * inset, + 2, fill=1, stroke=0) - # Clip text to block + # Draw text clipped to block p = c.beginPath() - p.rect(bx + 1, by + 1, bw - 2, bh - 2) + p.rect(bx + inset + 1, row_y + inset + 1, bw - 2 * inset - 2, row_h - 2 * inset - 2) c.clipPath(p, stroke=0, fill=0) set_fill(c, text_rgb) - lines = [block.title] - if block.responsible: - lines.append(block.responsible) - if len(lines) == 1 or bh < cell_font_size * 2.5: - c.setFont("Helvetica-Bold", cell_font_size) - c.drawCentredString(bx + bw / 2, by + (bh - cell_font_size) / 2, lines[0]) + title_line = block.title + (' →' if overnight else '') + if row_h > block_title_font * 2.2 and block.responsible: + # Two lines: title + responsible + c.setFont('Helvetica-Bold', block_title_font) + c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line) + c.setFont('Helvetica', block_time_font) + 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)) + c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1, + block.responsible) else: - c.setFont("Helvetica-Bold", cell_font_size) - c.drawCentredString(bx + bw / 2, by + bh / 2 + 1, lines[0]) - c.setFont("Helvetica", max(4.5, cell_font_size - 1.0)) - set_fill(c, (text_rgb[0] * 0.85, text_rgb[1] * 0.85, text_rgb[2] * 0.85) if is_light(pt.color) - else (0.9, 0.9, 0.9)) - c.drawCentredString(bx + bw / 2, by + bh / 2 - cell_font_size + 1, lines[1]) + c.setFont('Helvetica-Bold', block_title_font) + c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line) c.restoreState() # ── Legend ──────────────────────────────────────────────────────── - legend_y = table_bottom - 8 - legend_x = x0 - legend_label_h = legend_item_h + legend_y_top = table_top - num_days * row_h - 6 - c.setFont("Helvetica-Bold", 7) + c.setFont('Helvetica-Bold', 7) set_fill(c, HEADER_BG) - c.drawString(legend_x, legend_y, "Legenda:") - legend_y -= 4 - - legend_box_w = 10 * mm - legend_text_w = 50 * mm - legend_col_stride = legend_box_w + legend_text_w + 4 * mm - cols = max(1, int((PAGE_W - 2 * MARGIN) / legend_col_stride)) + c.drawString(x0, legend_y_top, 'Legenda:') + legend_y_top -= LEGEND_ITEM_H for i, pt in enumerate(doc.program_types): - col = i % cols - row_idx = i // cols - lx = legend_x + col * legend_col_stride - ly = legend_y - row_idx * legend_label_h + col = i % legend_cols + row_idx = i // legend_cols + lx = x0 + col * LEGEND_STRIDE + ly = legend_y_top - row_idx * LEGEND_ITEM_H fill_rgb = hex_to_rgb(pt.color) - text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0) - draw_rect_filled(c, lx, ly - legend_label_h + 2, legend_box_w, legend_label_h - 2, fill_rgb, GRID_LINE, 0.3) - c.setFont("Helvetica-Bold", 6.5) - set_fill(c, text_rgb) - c.drawCentredString(lx + legend_box_w / 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 6.5) / 2, pt.name[:10]) + # Colored square (NO text inside, just the color) + fill_rect(c, lx, ly - LEGEND_ITEM_H + 2, LEGEND_BOX_W, LEGEND_ITEM_H - 2, + fill_rgb, BORDER, 0.3) - c.setFont("Helvetica", 7) - set_fill(c, LABEL_TEXT) - c.drawString(lx + legend_box_w + 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 7) / 2, pt.name) + # Type name NEXT TO the square + c.setFont('Helvetica', 7) + set_fill(c, (0.15, 0.15, 0.15)) + c.drawString(lx + LEGEND_BOX_W + 3, + ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2, + pt.name) # ── Footer ──────────────────────────────────────────────────────── - gen_date = datetime.now().strftime("%d.%m.%Y %H:%M") - c.setFont("Helvetica-Oblique", 6.5) + gen_date = datetime.now().strftime('%d.%m.%Y %H:%M') + c.setFont('Helvetica-Oblique', 6.5) set_fill(c, FOOTER_TEXT) - c.drawCentredString(PAGE_W / 2, MARGIN - 2, f"Vygenerováno Scenár Creatorem | {gen_date}") + c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}') c.save() return buf.getvalue() diff --git a/app/models/event.py b/app/models/event.py index 128a4f6..9a7da6a 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -1,6 +1,7 @@ -"""Pydantic v2 models for Scenar Creator v3.""" +"""Pydantic v2 models for Scenar Creator v4.""" import uuid +from datetime import date as date_type, timedelta from typing import List, Optional from pydantic import BaseModel, Field @@ -8,9 +9,9 @@ from pydantic import BaseModel, Field class Block(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - date: str # "YYYY-MM-DD" - start: str # "HH:MM" - end: str # "HH:MM" + date: str # "YYYY-MM-DD" + start: str # "HH:MM" (can be > 24:00 for overnight continuation) + end: str # "HH:MM" (if end < start → overnight block) title: str type_id: str responsible: Optional[str] = None @@ -20,13 +21,16 @@ class Block(BaseModel): class ProgramType(BaseModel): id: str name: str - color: str # "#RRGGBB" + color: str # "#RRGGBB" class EventInfo(BaseModel): title: str subtitle: Optional[str] = None - date: Optional[str] = None + # Multi-day: date_from → date_to (inclusive). Backward compat: date = date_from. + date: Optional[str] = None # legacy / backward compat + date_from: Optional[str] = None + date_to: Optional[str] = None location: Optional[str] = None @@ -35,3 +39,8 @@ class ScenarioDocument(BaseModel): event: EventInfo program_types: List[ProgramType] blocks: List[Block] + + def get_sorted_dates(self) -> List[str]: + """Return sorted list of unique block dates.""" + dates = sorted(set(b.date for b in self.blocks)) + return dates diff --git a/app/static/css/app.css b/app/static/css/app.css index 8ebb531..7d02ac9 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -709,3 +709,169 @@ body { background: var(--border); color: var(--text); } + +/* ============================================================ + v4 — Horizontal Canvas (X=time, Y=days) + ============================================================ */ + +.canvas-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); +} + +.canvas-scroll-area { + flex: 1; + overflow: auto; + padding: 12px 16px; +} + +/* Time axis row */ +.time-axis-row { + display: flex; + align-items: flex-end; + margin-bottom: 2px; +} + +.time-corner { + background: transparent; + flex-shrink: 0; +} + +.time-tick { + position: absolute; + top: 4px; + font-size: 10px; + color: var(--text-light); + transform: translateX(-50%); + font-variant-numeric: tabular-nums; + pointer-events: none; + white-space: nowrap; +} + +/* Day rows */ +.day-rows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.day-row { + display: flex; + align-items: stretch; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.day-label { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 10px 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-light); + background: #f8fafc; + border-right: 1px solid var(--border); + flex-shrink: 0; + white-space: nowrap; +} + +.day-timeline { + flex: 1; + position: relative; + background: var(--white); + cursor: crosshair; + overflow: hidden; +} + +/* Hour grid lines in timeline */ +.grid-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--border); + pointer-events: none; + z-index: 1; +} + +/* Block element (horizontal) */ +.block-el { + position: absolute; + border-radius: 4px; + overflow: hidden; + cursor: grab; + z-index: 10; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: box-shadow 0.12s; + user-select: none; + touch-action: none; + display: flex; + align-items: center; +} + +.block-el:hover { + box-shadow: 0 3px 8px rgba(0,0,0,0.25); + z-index: 20; +} + +.block-el:active { + cursor: grabbing; +} + +.block-el.overnight { + opacity: 0.85; + border-right: 3px dashed rgba(255,255,255,0.5); +} + +.block-inner { + padding: 2px 6px; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + width: 100%; +} + +.block-title { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.block-time { + font-size: 9px; + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* Resize handle on right edge */ +.block-el::after { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 6px; + cursor: ew-resize; + background: rgba(255,255,255,0.2); + border-radius: 0 4px 4px 0; + opacity: 0; + transition: opacity 0.12s; +} + +.block-el:hover::after { + opacity: 1; +} diff --git a/app/static/index.html b/app/static/index.html index 58d0305..9c46ab5 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -13,7 +13,7 @@

Scenár Creator

- v3.0 + v4.0
-
- - +
+
+ + +
+
+ + +
@@ -66,69 +72,75 @@
-
-
-
-
-
-
+
+
+
+ - +