From 7a963eccd15f6b5c2c13a48a109a2bf241648e05 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Tue, 17 Feb 2026 23:01:07 +0300 Subject: [PATCH] Add event type handling for duties in the system - Introduced a new `event_type` column in the `duties` table to categorize duties as 'duty', 'unavailable', or 'vacation'. - Updated the duty schedule import functionality to parse and store event types from the JSON input. - Enhanced the API response to include event types for each duty, improving the calendar display logic. - Modified the web application to visually differentiate between duty types in the calendar and duty list. - Updated tests to cover new event type functionality and ensure correct parsing and storage of duties. - Revised README documentation to reflect changes in duty event types and their representation in the system. --- README.md | 2 +- alembic/versions/003_duties_event_type.py | 28 +++++++ api/app.py | 1 + db/models.py | 2 + db/repository.py | 11 ++- db/schemas.py | 5 +- handlers/import_duty_schedule.py | 41 +++++++--- importers/duty_schedule.py | 43 +++++++--- tests/test_duty_schedule_parser.py | 49 +++++++++--- .../test_import_duty_schedule_integration.py | 79 ++++++++++++++++--- webapp/app.js | 43 +++++++--- webapp/style.css | 35 +++++++- 12 files changed, 279 insertions(+), 60 deletions(-) create mode 100644 alembic/versions/003_duties_event_type.py diff --git a/README.md b/README.md index 18806de..0ba0f6d 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ To add commands, define async handlers in `handlers/commands.py` (or a new modul - **meta**: обязательное поле `start_date` (YYYY-MM-DD), опционально `weeks`; количество дней определяется по длине строки `duty`. - **schedule**: массив объектов с полями: - `name` — ФИО (строка); - - `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет дежурства; символы **б**, **Б**, **в**, **Н**, **О** — день дежурства. + - `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет события; **в**, **В**, **б**, **Б** — дежурство; **Н** — недоступен; **О** — отпуск. При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми. diff --git a/alembic/versions/003_duties_event_type.py b/alembic/versions/003_duties_event_type.py new file mode 100644 index 0000000..aeae53e --- /dev/null +++ b/alembic/versions/003_duties_event_type.py @@ -0,0 +1,28 @@ +"""Duties: add event_type (duty | unavailable | vacation) + +Revision ID: 003 +Revises: 002 +Create Date: 2025-02-17 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "003" +down_revision: Union[str, None] = "002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "duties", + sa.Column("event_type", sa.Text(), nullable=False, server_default="duty"), + ) + + +def downgrade() -> None: + op.drop_column("duties", "event_type") diff --git a/api/app.py b/api/app.py index 9e49dd8..dfbcdbc 100644 --- a/api/app.py +++ b/api/app.py @@ -46,6 +46,7 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]: start_at=duty.start_at, end_at=duty.end_at, full_name=full_name, + event_type=duty.event_type, ) for duty, full_name in rows ] diff --git a/db/models.py b/db/models.py index e1762aa..68e4419 100644 --- a/db/models.py +++ b/db/models.py @@ -35,5 +35,7 @@ class Duty(Base): # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z) start_at: Mapped[str] = mapped_column(Text, nullable=False) end_at: Mapped[str] = mapped_column(Text, nullable=False) + # duty | unavailable | vacation + event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="duty") user: Mapped["User"] = relationship("User", back_populates="duties") diff --git a/db/repository.py b/db/repository.py index e45ca10..0001005 100644 --- a/db/repository.py +++ b/db/repository.py @@ -102,9 +102,16 @@ def insert_duty( user_id: int, start_at: str, end_at: str, + event_type: str = "duty", ) -> Duty: - """Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).""" - duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at) + """Create a duty. start_at and end_at must be UTC, ISO 8601 with Z. + event_type: 'duty' | 'unavailable' | 'vacation'.""" + duty = Duty( + user_id=user_id, + start_at=start_at, + end_at=end_at, + event_type=event_type, + ) session.add(duty) session.commit() session.refresh(duty) diff --git a/db/schemas.py b/db/schemas.py index 6fdd654..56a3e0d 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -1,5 +1,7 @@ """Pydantic schemas for API and validation.""" +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -38,9 +40,10 @@ class DutyInDb(DutyBase): class DutyWithUser(DutyInDb): - """Duty with full_name for calendar display.""" + """Duty with full_name and event_type for calendar display.""" full_name: str + event_type: Literal["duty", "unavailable", "vacation"] = "duty" model_config = ConfigDict(from_attributes=True) diff --git a/handlers/import_duty_schedule.py b/handlers/import_duty_schedule.py index b0af60e..8737fd5 100644 --- a/handlers/import_duty_schedule.py +++ b/handlers/import_duty_schedule.py @@ -101,22 +101,37 @@ def _run_import( result: DutyScheduleResult, hour_utc: int, minute_utc: int, -) -> tuple[int, int]: +) -> tuple[int, int, int, int]: + """Returns (num_users, num_duty, num_unavailable, num_vacation).""" session = get_session(database_url) try: from_date_str = result.start_date.isoformat() to_date_str = result.end_date.isoformat() - total_duties = 0 - for full_name, duty_dates in result.entries: - user = get_or_create_user_by_full_name(session, full_name) + num_duty = num_unavailable = num_vacation = 0 + for entry in result.entries: + user = get_or_create_user_by_full_name(session, entry.full_name) delete_duties_in_range(session, user.id, from_date_str, to_date_str) - for d in duty_dates: + for d in entry.duty_dates: start_at = _duty_to_iso(d, hour_utc, minute_utc) d_next = d + timedelta(days=1) end_at = _duty_to_iso(d_next, hour_utc, minute_utc) - insert_duty(session, user.id, start_at, end_at) - total_duties += 1 - return (len(result.entries), total_duties) + insert_duty(session, user.id, start_at, end_at, event_type="duty") + num_duty += 1 + for d in entry.unavailable_dates: + start_at = _duty_to_iso(d, hour_utc, minute_utc) + d_next = d + timedelta(days=1) + end_at = _duty_to_iso(d_next, hour_utc, minute_utc) + insert_duty( + session, user.id, start_at, end_at, event_type="unavailable" + ) + num_unavailable += 1 + for d in entry.vacation_dates: + start_at = _duty_to_iso(d, hour_utc, minute_utc) + d_next = d + timedelta(days=1) + end_at = _duty_to_iso(d_next, hour_utc, minute_utc) + insert_duty(session, user.id, start_at, end_at, event_type="vacation") + num_vacation += 1 + return (len(result.entries), num_duty, num_unavailable, num_vacation) finally: session.close() @@ -149,15 +164,21 @@ async def handle_duty_schedule_document(update: Update, context: ContextTypes.DE loop = asyncio.get_running_loop() try: - num_users, num_duties = await loop.run_in_executor( + num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor( None, lambda: _run_import(config.DATABASE_URL, result, hour_utc, minute_utc), ) except Exception as e: await update.message.reply_text(f"Ошибка импорта: {e}") else: + total = num_duty + num_unavailable + num_vacation + parts = [f"{num_users} пользователей", f"{num_duty} дежурств"] + if num_unavailable: + parts.append(f"{num_unavailable} недоступностей") + if num_vacation: + parts.append(f"{num_vacation} отпусков") await update.message.reply_text( - f"Импорт выполнен: {num_users} пользователей, {num_duties} дежурств." + "Импорт выполнен: " + ", ".join(parts) + f" (всего {total} событий)." ) finally: context.user_data.pop("awaiting_duty_schedule_file", None) diff --git a/importers/duty_schedule.py b/importers/duty_schedule.py index c3f8e6a..0f68d87 100644 --- a/importers/duty_schedule.py +++ b/importers/duty_schedule.py @@ -4,17 +4,29 @@ import json from dataclasses import dataclass from datetime import date, timedelta -# Символы, обозначающие день дежурства в ячейке duty (CSV с разделителем ;) -DUTY_MARKERS = frozenset({"б", "Б", "в", "Н", "О"}) +# Символы дежурства в ячейке duty (CSV с разделителем ;) +DUTY_MARKERS = frozenset({"б", "Б", "в", "В"}) +UNAVAILABLE_MARKER = "Н" +VACATION_MARKER = "О" + + +@dataclass +class DutyScheduleEntry: + """One person's schedule: full_name and three lists of dates by event type.""" + + full_name: str + duty_dates: list[date] + unavailable_dates: list[date] + vacation_dates: list[date] @dataclass class DutyScheduleResult: - """Parsed duty schedule: start_date, end_date, and per-person duty dates.""" + """Parsed duty schedule: start_date, end_date, and per-person entries.""" start_date: date end_date: date - entries: list[tuple[str, list[date]]] # (full_name, list of duty dates) + entries: list[DutyScheduleEntry] class DutyScheduleParseError(Exception): @@ -24,12 +36,12 @@ class DutyScheduleParseError(Exception): def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult: - """Parse duty-schedule JSON. Returns start_date, end_date, and list of (full_name, duty_dates). + """Parse duty-schedule JSON. Returns start_date, end_date, and list of DutyScheduleEntry. - meta.start_date (YYYY-MM-DD) and schedule (array) required. - meta.weeks optional; number of days from max duty string length (split by ';'). - For each schedule item: name (required), duty = CSV with ';'; index i = start_date + i days. - - Cell value after strip in DUTY_MARKERS => duty day. + - Cell value after strip: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored. """ try: data = json.loads(raw_bytes.decode("utf-8")) @@ -53,7 +65,7 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult: raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)") max_days = 0 - entries: list[tuple[str, list[date]]] = [] + entries: list[DutyScheduleEntry] = [] for row in schedule: if not isinstance(row, dict): @@ -75,11 +87,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult: max_days = max(max_days, len(cells)) duty_dates: list[date] = [] + unavailable_dates: list[date] = [] + vacation_dates: list[date] = [] for i, cell in enumerate(cells): + d = start_date + timedelta(days=i) if cell in DUTY_MARKERS: - d = start_date + timedelta(days=i) duty_dates.append(d) - entries.append((full_name, duty_dates)) + elif cell == UNAVAILABLE_MARKER: + unavailable_dates.append(d) + elif cell == VACATION_MARKER: + vacation_dates.append(d) + entries.append( + DutyScheduleEntry( + full_name=full_name, + duty_dates=duty_dates, + unavailable_dates=unavailable_dates, + vacation_dates=vacation_dates, + ) + ) if max_days == 0: end_date = start_date diff --git a/tests/test_duty_schedule_parser.py b/tests/test_duty_schedule_parser.py index 6472e95..8fc64a7 100644 --- a/tests/test_duty_schedule_parser.py +++ b/tests/test_duty_schedule_parser.py @@ -6,7 +6,10 @@ import pytest from importers.duty_schedule import ( DUTY_MARKERS, + UNAVAILABLE_MARKER, + VACATION_MARKER, DutyScheduleParseError, + DutyScheduleEntry, parse_duty_schedule, ) @@ -24,16 +27,19 @@ def test_parse_valid_schedule(): # Petrov has 7 cells -> end = start + 6 assert result.end_date == date(2026, 2, 22) assert len(result.entries) == 2 - names = [e[0] for e in result.entries] - assert "Ivanov I.I." in names - assert "Petrov P.P." in names - by_name = {e[0]: e[1] for e in result.entries} - # Ivanov: indices 2, 3, 4 are duty (б, Б, в) -> 2026-02-18, 19, 20 - ivan_dates = sorted(by_name["Ivanov I.I."]) - assert ivan_dates == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)] - # Petrov: indices 1, 2 (Н, О) -> 2026-02-17, 18 - petr_dates = sorted(by_name["Petrov P.P."]) - assert petr_dates == [date(2026, 2, 17), date(2026, 2, 18)] + by_name = {e.full_name: e for e in result.entries} + assert "Ivanov I.I." in by_name + assert "Petrov P.P." in by_name + # Ivanov: only duty (б, Б, в) -> 2026-02-18, 19, 20 + ivan = by_name["Ivanov I.I."] + assert sorted(ivan.duty_dates) == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)] + assert ivan.unavailable_dates == [] + assert ivan.vacation_dates == [] + # Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18 + petr = by_name["Petrov P.P."] + assert petr.duty_dates == [] + assert petr.unavailable_dates == [date(2026, 2, 17)] + assert petr.vacation_dates == [date(2026, 2, 18)] def test_parse_empty_duty_string(): @@ -41,7 +47,11 @@ def test_parse_empty_duty_string(): result = parse_duty_schedule(raw) assert result.start_date == date(2026, 2, 1) assert result.end_date == date(2026, 2, 1) - assert result.entries == [("A", [])] + assert len(result.entries) == 1 + assert result.entries[0].full_name == "A" + assert result.entries[0].duty_dates == [] + assert result.entries[0].unavailable_dates == [] + assert result.entries[0].vacation_dates == [] def test_parse_invalid_json(): @@ -89,4 +99,19 @@ def test_parse_schedule_item_empty_name(): def test_duty_markers(): - assert "б" in DUTY_MARKERS and "Б" in DUTY_MARKERS and "в" in DUTY_MARKERS + assert DUTY_MARKERS == frozenset({"б", "Б", "в", "В"}) + assert "Н" not in DUTY_MARKERS and "О" not in DUTY_MARKERS + + +def test_unavailable_and_vacation_markers(): + assert UNAVAILABLE_MARKER == "Н" + assert VACATION_MARKER == "О" + raw = ( + '{"meta": {"start_date": "2026-02-01"}, "schedule": [' + '{"name": "X", "duty": "\u041d; \u041e; \u0432"}]}' + ).encode("utf-8") + result = parse_duty_schedule(raw) + entry = result.entries[0] + assert entry.unavailable_dates == [date(2026, 2, 1)] + assert entry.vacation_dates == [date(2026, 2, 2)] + assert entry.duty_dates == [date(2026, 2, 3)] diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 7b2213e..1c68683 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -5,10 +5,9 @@ from datetime import date import pytest from db import init_db -from db.models import Base from db.repository import get_duties from db.session import get_session -from importers.duty_schedule import DutyScheduleResult, parse_duty_schedule +from importers.duty_schedule import DutyScheduleEntry, DutyScheduleResult, parse_duty_schedule from handlers.import_duty_schedule import _run_import @@ -36,17 +35,28 @@ def test_import_creates_users_and_duties(db_url): start_date=date(2026, 2, 16), end_date=date(2026, 2, 18), entries=[ - ("Ivanov I.I.", [date(2026, 2, 16), date(2026, 2, 18)]), - ("Petrov P.P.", [date(2026, 2, 17)]), + DutyScheduleEntry( + full_name="Ivanov I.I.", + duty_dates=[date(2026, 2, 16), date(2026, 2, 18)], + unavailable_dates=[], + vacation_dates=[], + ), + DutyScheduleEntry( + full_name="Petrov P.P.", + duty_dates=[date(2026, 2, 17)], + unavailable_dates=[], + vacation_dates=[], + ), ], ) - num_users, num_duties = _run_import(db_url, result, 6, 0) + num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0) assert num_users == 2 - assert num_duties == 3 + assert num_duty == 3 + assert num_unav == 0 + assert num_vac == 0 session = get_session(db_url) try: - # to_date inclusive: duty on 18th has start_at 2026-02-18T06:00:00Z, so use 2026-02-19 duties = get_duties(session, "2026-02-16", "2026-02-19") finally: session.close() @@ -56,6 +66,8 @@ def test_import_creates_users_and_duties(db_url): assert "2026-02-16T06:00:00Z" in starts assert "2026-02-17T06:00:00Z" in starts assert "2026-02-18T06:00:00Z" in starts + for d, _ in duties: + assert d.event_type == "duty" def test_import_replaces_duties_in_range(db_url): @@ -63,7 +75,14 @@ def test_import_replaces_duties_in_range(db_url): result1 = DutyScheduleResult( start_date=date(2026, 2, 16), end_date=date(2026, 2, 17), - entries=[("Sidorov", [date(2026, 2, 16), date(2026, 2, 17)])], + entries=[ + DutyScheduleEntry( + full_name="Sidorov", + duty_dates=[date(2026, 2, 16), date(2026, 2, 17)], + unavailable_dates=[], + vacation_dates=[], + ) + ], ) _run_import(db_url, result1, 9, 0) @@ -77,7 +96,14 @@ def test_import_replaces_duties_in_range(db_url): result2 = DutyScheduleResult( start_date=date(2026, 2, 16), end_date=date(2026, 2, 17), - entries=[("Sidorov", [date(2026, 2, 17)])], + entries=[ + DutyScheduleEntry( + full_name="Sidorov", + duty_dates=[date(2026, 2, 17)], + unavailable_dates=[], + vacation_dates=[], + ) + ], ) _run_import(db_url, result2, 9, 0) @@ -97,9 +123,11 @@ def test_import_full_flow_parse_then_import(db_url): '"schedule": [{"name": "Alexey A.", "duty": "\u0431; ; \u0432"}]}' ).encode("utf-8") parsed = parse_duty_schedule(raw) - num_users, num_duties = _run_import(db_url, parsed, 6, 0) + num_users, num_duty, num_unav, num_vac = _run_import(db_url, parsed, 6, 0) assert num_users == 1 - assert num_duties == 2 + assert num_duty == 2 + assert num_unav == 0 + assert num_vac == 0 session = get_session(db_url) try: @@ -108,3 +136,32 @@ def test_import_full_flow_parse_then_import(db_url): session.close() assert len(duties) == 2 assert duties[0][1] == "Alexey A." + assert duties[0][0].event_type == "duty" + + +def test_import_event_types_unavailable_vacation(db_url): + """Import creates records with event_type duty, unavailable, vacation.""" + result = DutyScheduleResult( + start_date=date(2026, 2, 16), + end_date=date(2026, 2, 18), + entries=[ + DutyScheduleEntry( + full_name="Mixed User", + duty_dates=[date(2026, 2, 16)], + unavailable_dates=[date(2026, 2, 17)], + vacation_dates=[date(2026, 2, 18)], + ), + ], + ) + num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0) + assert num_users == 1 + assert num_duty == 1 and num_unav == 1 and num_vac == 1 + + session = get_session(db_url) + try: + duties = get_duties(session, "2026-02-16", "2026-02-19") + finally: + session.close() + assert len(duties) == 3 + types = {d[0].event_type for d in duties} + assert types == {"duty", "unavailable", "vacation"} diff --git a/webapp/app.js b/webapp/app.js index f987856..b993578 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -198,18 +198,34 @@ const key = localDateString(d); const isOther = d.getMonth() !== month; const dayDuties = dutiesByDate[key] || []; + const dutyList = dayDuties.filter(function (x) { return x.event_type === "duty"; }); + const unavailableList = dayDuties.filter(function (x) { return x.event_type === "unavailable"; }); + const vacationList = dayDuties.filter(function (x) { return x.event_type === "vacation"; }); + const hasAny = dayDuties.length > 0; const isToday = key === today; const eventSummaries = calendarEventsByDate[key] || []; const hasEvent = eventSummaries.length > 0; const cell = document.createElement("div"); - cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : ""); - const dutyNamesAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join("\n")) : ""; - const dutyTitleAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join(", ")) : ""; - cell.innerHTML = - "" + d.getDate() + "" + - (dayDuties.length ? "Д" : "") + - (hasEvent ? "" : ""); + cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (hasAny ? " has-duty" : "") + (hasEvent ? " holiday" : ""); + + function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; } + function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; } + + let markers = "" + d.getDate() + ""; + if (dutyList.length) { + markers += "Д"; + } + if (unavailableList.length) { + markers += "Н"; + } + if (vacationList.length) { + markers += "О"; + } + if (hasEvent) { + markers += ""; + } + cell.innerHTML = markers; calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } @@ -312,13 +328,14 @@ document.body.appendChild(hintEl); } var hideTimeout = null; - calendarEl.querySelectorAll(".duty-marker").forEach(function (marker) { + var selector = ".duty-marker, .unavailable-marker, .vacation-marker"; + calendarEl.querySelectorAll(selector).forEach(function (marker) { marker.addEventListener("mouseenter", function () { if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } - var names = marker.getAttribute("data-duty-names") || ""; + var names = marker.getAttribute("data-names") || ""; hintEl.textContent = names; var rect = marker.getBoundingClientRect(); positionHint(hintEl, rect); @@ -333,9 +350,11 @@ }); } + var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" }; + function renderDutyList(duties) { if (duties.length === 0) { - dutyListEl.innerHTML = "

В этом месяце дежурств нет.

"; + dutyListEl.innerHTML = "

В этом месяце событий нет.

"; return; } const grouped = {}; @@ -354,7 +373,9 @@ const endDate = new Date(d.end_at); const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0"); const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0"); - html += "
" + escapeHtml(d.full_name) + "
" + start + " – " + end + "
"; + const typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type; + const itemClass = "duty-item duty-item--" + (d.event_type || "duty"); + html += "
" + escapeHtml(typeLabel) + " " + escapeHtml(d.full_name) + "
" + start + " – " + end + "
"; }); }); dutyListEl.innerHTML = html; diff --git a/webapp/style.css b/webapp/style.css index f6e4977..8a1a8a4 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -164,7 +164,9 @@ body { transform: none; } -.duty-marker { +.duty-marker, +.unavailable-marker, +.vacation-marker { display: inline-flex; align-items: center; justify-content: center; @@ -173,12 +175,25 @@ body { margin-top: 2px; font-size: 0.65rem; font-weight: 700; - color: var(--duty); - background: rgba(158, 206, 106, 0.25); border-radius: 50%; flex-shrink: 0; } +.duty-marker { + color: var(--duty); + background: rgba(158, 206, 106, 0.25); +} + +.unavailable-marker { + color: #e0af68; + background: rgba(224, 175, 104, 0.25); +} + +.vacation-marker { + color: #7dcfff; + background: rgba(125, 207, 255, 0.25); +} + .duty-list { font-size: 0.9rem; } @@ -197,6 +212,20 @@ body { border-left: 3px solid var(--duty); } +.duty-item--unavailable { + border-left-color: #e0af68; +} + +.duty-item--vacation { + border-left-color: #7dcfff; +} + +.duty-item .duty-item-type { + font-size: 0.75rem; + color: var(--muted); + margin-right: 4px; +} + .duty-item .name { font-weight: 600; }