Add gantt rendering

This commit is contained in:
Hubert Van De Walle
2026-08-21 12:04:01 +02:00
parent 415871ba6f
commit 2187f1dfc3
4 changed files with 338 additions and 23 deletions
+1
View File
@@ -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]
+20
View File
@@ -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()
+259
View File
@@ -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)
+58 -23
View File
@@ -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