From 2187f1dfc35816c8ea1c574cad1be227d14bf877 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Fri, 21 Aug 2026 12:04:01 +0200 Subject: [PATCH] Add gantt rendering --- pyproject.toml | 1 + src/odoo_employee_schedule/cli.py | 20 ++ src/odoo_employee_schedule/render.py | 259 +++++++++++++++++++++++++ src/odoo_employee_schedule/schedule.py | 81 +++++--- 4 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 src/odoo_employee_schedule/render.py diff --git a/pyproject.toml b/pyproject.toml index e85965a..6c9c304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Query Odoo for an employee's working days in a date range, includ requires-python = ">=3.11" dependencies = [ "requests>=2.31", + "rich>=15.0.0", ] [project.scripts] diff --git a/src/odoo_employee_schedule/cli.py b/src/odoo_employee_schedule/cli.py index 33f4fdb..a8d78d2 100644 --- a/src/odoo_employee_schedule/cli.py +++ b/src/odoo_employee_schedule/cli.py @@ -7,7 +7,10 @@ from datetime import date from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from rich.console import Console + from .client import OdooClient, OdooError +from .render import render_gantt from .schedule import DAY_NAMES, get_schedules, resolve_employees @@ -93,6 +96,17 @@ def main() -> int: action="store_true", help="Log each outgoing request (model/method/args, not the response) to stderr", ) + parser.add_argument( + "--gantt", + action="store_true", + help="Show a compact colored gantt-like grid (one row per employee) instead of the per-day text list", + ) + parser.add_argument( + "--details", + action="store_true", + help="With --gantt, also print the Details section (specific leave/holiday names, pending " + "requests) below the grid; off by default", + ) args = parser.parse_args() if args.date_from > args.date_to: @@ -133,6 +147,12 @@ def main() -> int: print(f"error calling Odoo: {exc}", file=sys.stderr) return 1 + if args.gantt: + console = Console() + console.print(f"{args.date_from} to {args.date_to} [{tz_name}]\n") + render_gantt(console, schedules, employees, args.include_weekends, show_details=args.details) + return 0 + for i, emp in enumerate(employees): if i: print() diff --git a/src/odoo_employee_schedule/render.py b/src/odoo_employee_schedule/render.py new file mode 100644 index 0000000..2e2af98 --- /dev/null +++ b/src/odoo_employee_schedule/render.py @@ -0,0 +1,259 @@ +"""Render employee schedules as a compact terminal gantt-like grid using rich. + +Each day is one character wide. A day where morning and afternoon share the +same status renders as a solid block ("█") in that status's color. A half +day off renders as an upper-half-block glyph ("▀") whose foreground paints +the morning half and background paints the afternoon half, packing both +halves into a single character cell -- e.g. a green-over-magenta "▀" reads +as "worked the morning, on leave in the afternoon" at a glance. + +A wide date range (more day-columns than fit the terminal) is split into +several week-aligned pages rather than crammed into one table: past a +certain width Rich keeps the table frame but silently blanks cell content +it has no room for, so without paging a two-month request would render as +an empty grid. +""" + +from __future__ import annotations + +from rich import box +from rich.console import Console +from rich.measure import Measurement +from rich.table import Table +from rich.text import Text + +from .schedule import ( + DAY_NAMES, + REASON_LEAVE, + REASON_PUBLIC_HOLIDAY, + REASON_WEEKLY_OFF, + REASON_WORKING, + DaySchedule, + ResolvedEmployee, +) + +STATUS_COLOR = { + REASON_WORKING: "green", + REASON_WEEKLY_OFF: "bright_black", + REASON_PUBLIC_HOLIDAY: "cyan", + REASON_LEAVE: "magenta", +} + +LEGEND_ORDER = [ + (REASON_WORKING, "working"), + (REASON_WEEKLY_OFF, "not scheduled (weekly day off)"), + (REASON_PUBLIC_HOLIDAY, "public holiday"), + (REASON_LEAVE, "approved leave"), +] + +FULL_BLOCK = "█" # █ +HALF_BLOCK = "▀" # ▀ -- foreground = top (morning), background = bottom (afternoon) + +DAY_COL_WIDTH = 2 +SEP_COL_WIDTH = 1 +EMPLOYEE_COL_MIN_WIDTH = 14 +EMPLOYEE_COL_MAX_WIDTH = 30 + + +def _day_cell(day: DaySchedule) -> Text: + top = STATUS_COLOR[day.morning_reason] + bottom = STATUS_COLOR[day.afternoon_reason] + # Always emit exactly DAY_COL_WIDTH real glyph characters rather than one + # glyph plus a rich-padded space: a padded space nominally inherits the + # same "fg on bg" style, but some terminals render a half-block glyph + # immediately followed by a plain space (both with a background color + # set) as a garbled diagonal/checkerboard pattern instead of a clean + # split. Repeating the real glyph sidesteps relying on that padding path. + if top == bottom: + return Text(FULL_BLOCK * DAY_COL_WIDTH, style=top) + return Text(HALF_BLOCK * DAY_COL_WIDTH, style=f"{top} on {bottom}") + + +def _visible_days(days: list[DaySchedule], include_weekends: bool) -> list[DaySchedule]: + return [d for d in days if include_weekends or d.day.weekday() < 5] + + +def _week_groups(days: list[DaySchedule]) -> list[list[DaySchedule]]: + """Split into whole calendar weeks (Monday-starting), first/last possibly partial.""" + groups: list[list[DaySchedule]] = [] + current: list[DaySchedule] = [] + for d in days: + if current and d.day.weekday() == 0: + groups.append(current) + current = [] + current.append(d) + if current: + groups.append(current) + return groups + + +def _table_fits(console: Console, days: list[DaySchedule], employee_col_width: int) -> bool: + """Whether a page of exactly these days would render without Rich having to + shrink/truncate columns below what they were declared with -- measured + against the real console, not guessed from a hand-rolled width formula + (which kept being wrong: overhead like box-drawing edges and the exact + count of once-per-week separator columns is fiddly to get right by hand, + and getting it wrong in the "too many columns" direction makes Rich + silently truncate day-of-month headers to an ellipsis). + + `console.options` already caps `max_width` at `console.width`, so + measuring with it directly always reports minimum <= console.width -- + tautologically "fits" no matter how wide the table really is. Measuring + with an unbounded max_width instead gives the table's true required + width, which we then compare against console.width ourselves. + """ + table = _build_page_table(_with_week_separators(days), employee_col_width) + unbounded = console.options.update(max_width=10**6) + measurement = Measurement.get(console, unbounded, table) + return measurement.minimum <= console.width + + +def _paginate(console: Console, days: list[DaySchedule], employee_col_width: int) -> list[tuple[int, int]]: + """Index ranges splitting `days` into pages, packing whole weeks (never + splitting one across pages) up to exactly as many as still measure + within the console's width. A page always gets at least one full week + even if that alone doesn't fit -- nothing more can be done at that point. + """ + ranges = [] + page_start = 0 + idx = 0 + current_groups: list[list[DaySchedule]] = [] + for group in _week_groups(days): + candidate_groups = current_groups + [group] + if current_groups and not _table_fits( + console, [d for g in candidate_groups for d in g], employee_col_width + ): + ranges.append((page_start, idx)) + page_start = idx + current_groups = [group] + else: + current_groups = candidate_groups + idx += len(group) + ranges.append((page_start, idx)) + return ranges + + +def _with_week_separators(days: list[DaySchedule]) -> list[DaySchedule | None]: + """`days` with a `None` marker spliced in between calendar weeks. + + Single source of truth for where separator slots go, consumed by both + the table's column headers and by each row's cell values -- building + these independently is exactly how the row values previously ended up + silently shifted left past every separator, landing under the wrong day. + """ + result: list[DaySchedule | None] = [] + prev_weekday = None + for d in days: + weekday = d.day.weekday() + if prev_weekday is not None and weekday < prev_weekday: + result.append(None) + result.append(d) + prev_weekday = weekday + return result + + +def _build_page_table(slots: list[DaySchedule | None], employee_col_width: int) -> Table: + table = Table(box=box.SIMPLE_HEAVY, pad_edge=False, show_edge=False, show_lines=True) + table.add_column("Employee", no_wrap=True, width=employee_col_width, overflow="ellipsis") + for slot in slots: + if slot is None: + table.add_column("", width=SEP_COL_WIDTH) # blank separator between calendar weeks + else: + header = f"{DAY_NAMES[slot.day.weekday()][0]}\n{slot.day.day:02d}" + table.add_column(header, justify="center", width=DAY_COL_WIDTH, no_wrap=True) + return table + + +def _row_cells(slot_pattern: list[DaySchedule | None], emp_days: list[DaySchedule]) -> list[Text]: + """Cells for one employee's row, laid out on `slot_pattern`'s separator positions. + + `slot_pattern` comes from the reference employee (see `render_gantt`) and + `emp_days` is this employee's own same-length, same-date-order day list; + walking them in lockstep -- rather than recomputing separator positions + from `emp_days` a second time -- is what actually keeps every employee's + row aligned with the shared header by construction, not by coincidence. + """ + cells = [] + it = iter(emp_days) + for slot in slot_pattern: + cells.append(Text("") if slot is None else _day_cell(next(it))) + return cells + + +def render_gantt( + console: Console, + schedules: dict[int, list[DaySchedule]], + employees: list[ResolvedEmployee], + include_weekends: bool, + show_details: bool = False, +) -> None: + if not employees: + return + + visible_by_emp = {emp.id: _visible_days(schedules[emp.id], include_weekends) for emp in employees} + reference_days = visible_by_emp[employees[0].id] + if not reference_days: + return + + employee_col_width = max( + EMPLOYEE_COL_MIN_WIDTH, min(EMPLOYEE_COL_MAX_WIDTH, max(len(e.name) for e in employees)) + ) + ranges = _paginate(console, reference_days, employee_col_width) + + for page_num, (start, end) in enumerate(ranges): + page_days = reference_days[start:end] + if len(ranges) > 1: + console.print(f"[{page_days[0].day} - {page_days[-1].day}]", style="dim") + slot_pattern = _with_week_separators(page_days) + table = _build_page_table(slot_pattern, employee_col_width) + for emp in employees: + row = [emp.name] + _row_cells(slot_pattern, visible_by_emp[emp.id][start:end]) + table.add_row(*row) + console.print(table) + if page_num < len(ranges) - 1: + console.print() + + console.print() + legend = Text("Legend: ", style="bold") + for i, (reason, label) in enumerate(LEGEND_ORDER): + if i: + legend.append(" ") + legend.append(FULL_BLOCK * DAY_COL_WIDTH, style=STATUS_COLOR[reason]) + legend.append(" " + label) + console.print(legend) + half_example = Text() + half_example.append( + HALF_BLOCK * DAY_COL_WIDTH, style=f"{STATUS_COLOR[REASON_WORKING]} on {STATUS_COLOR[REASON_LEAVE]}" + ) + half_example.append(" = half day (top = morning, bottom = afternoon)", style="dim") + console.print(half_example) + + if show_details: + _print_footnotes(console, schedules, employees, include_weekends) + + +def _print_footnotes( + console: Console, + schedules: dict[int, list[DaySchedule]], + employees: list[ResolvedEmployee], + include_weekends: bool, +) -> None: + """Specific leave/holiday names and pending requests, lost by the grid's broad color categories.""" + any_notes = False + lines: list[str] = [] + for emp in employees: + emp_lines = [] + for d in _visible_days(schedules[emp.id], include_weekends): + for note in d.notes: + if note.startswith(("leave:", "morning: leave:", "afternoon: leave:")) or "pending leave request" in note: + emp_lines.append(f" {d.day}: {note}") + if emp_lines: + any_notes = True + lines.append(f"{emp.name}:") + lines.extend(emp_lines) + + if any_notes: + console.print() + console.print(Text("Details:", style="bold")) + for line in lines: + console.print(line) diff --git a/src/odoo_employee_schedule/schedule.py b/src/odoo_employee_schedule/schedule.py index aec2ce2..ab05348 100644 --- a/src/odoo_employee_schedule/schedule.py +++ b/src/odoo_employee_schedule/schedule.py @@ -57,11 +57,21 @@ def or_domain(atoms: list[tuple]) -> list: return ["|"] * (len(atoms) - 1) + list(atoms) +# Reason categories for a non-working half-day, used both for the text notes +# and (see render.py) to color the terminal gantt grid. +REASON_WORKING = "working" +REASON_WEEKLY_OFF = "weekly_off" +REASON_PUBLIC_HOLIDAY = "public_holiday" +REASON_LEAVE = "leave" + + @dataclass class DaySchedule: day: date morning_working: bool afternoon_working: bool + morning_reason: str = REASON_WORKING + afternoon_reason: str = REASON_WORKING notes: list[str] = field(default_factory=list) @property @@ -250,6 +260,22 @@ def _overlaps(a_start: datetime, a_stop: datetime, b_start: datetime, b_stop: da return a_start < b_stop and b_start < a_stop +def _categorize_off_half( + w_start: datetime, + w_stop: datetime, + leave_periods: list[LeavePeriod], + holidays: list[tuple[datetime, datetime, str]], +) -> tuple[str, str | None]: + """Why a non-working half-day is off: (reason category, specific name or None).""" + for p in leave_periods: + if p.validated and _overlaps(w_start, w_stop, p.start, p.stop): + return REASON_LEAVE, p.label + for h_start, h_stop, name in holidays: + if _overlaps(w_start, w_stop, h_start, h_stop): + return REASON_PUBLIC_HOLIDAY, name + return REASON_WEEKLY_OFF, None + + def _build_days( date_from: date, date_to: date, @@ -275,40 +301,49 @@ def _build_days( morning_working = not _point_covered(unavailable_intervals, morning_anchor) afternoon_working = not _point_covered(unavailable_intervals, afternoon_anchor) + # Reason categories are computed regardless of `explain` -- the gantt + # grid's colors need them even when text notes are suppressed. Only + # the specific name (leave type / holiday name) and free-text notes + # are gated behind `explain`, since the public-holiday lookup itself + # is skipped to save a request when `explain` is off (see + # get_schedules): in that case a public holiday can't be told apart + # from a plain weekly day off and falls back to that category. + morning_reason = REASON_WORKING + afternoon_reason = REASON_WORKING + morning_label = None + afternoon_label = None + if not morning_working: + morning_reason, morning_label = _categorize_off_half(morning_start, noon, leave_periods, holidays) + if not afternoon_working: + afternoon_reason, afternoon_label = _categorize_off_half(noon, day_end, leave_periods, holidays) + notes = [] if explain: if leave_fetch_failed: notes.append("could not fetch this employee's leave requests (access denied) -- personal leave not reflected") - off_windows = [] - if not morning_working: - off_windows.append(("morning", morning_start, noon)) - if not afternoon_working: - off_windows.append(("afternoon", noon, day_end)) + def _describe(reason: str, label: str | None) -> str: + if reason == REASON_LEAVE: + return f"leave: {label}" + if reason == REASON_PUBLIC_HOLIDAY: + return f"public holiday: {label}" + return "not scheduled to work (weekly day off)" - for label, w_start, w_stop in off_windows: - reason = None - for p in leave_periods: - if p.validated and _overlaps(w_start, w_stop, p.start, p.stop): - reason = f"leave: {p.label}" - break - if reason is None: - for h_start, h_stop, name in holidays: - if _overlaps(w_start, w_stop, h_start, h_stop): - reason = f"public holiday: {name}" - break - if reason is None: - reason = "not scheduled to work (weekly day off)" - prefix = "" if (not morning_working and not afternoon_working) else f"{label}: " - notes.append(prefix + reason) - if not morning_working and not afternoon_working: - break # same reason applies to the whole day, don't repeat it + if not morning_working and not afternoon_working and morning_reason == afternoon_reason and morning_label == afternoon_label: + notes.append(_describe(morning_reason, morning_label)) + else: + if not morning_working: + notes.append("morning: " + _describe(morning_reason, morning_label)) + if not afternoon_working: + notes.append("afternoon: " + _describe(afternoon_reason, afternoon_label)) for p in leave_periods: if not p.validated and _overlaps(morning_start, day_end, p.start, p.stop): notes.append(f"pending leave request: {p.label} (awaiting approval)") - days.append(DaySchedule(d, morning_working, afternoon_working, notes)) + days.append( + DaySchedule(d, morning_working, afternoon_working, morning_reason, afternoon_reason, notes) + ) d += timedelta(days=1) return days