afterhours-shift-manager/src/blocks.py
Adam Moussa 3a51b52ffc Extend schedule display to show 2 full weeks
Anchors to the current week's Monday and shows 14 consecutive days.
Updates /oncall next to jump forward by 2 weeks accordingly.

Closes #1
2026-04-07 18:01:10 -04:00

132 lines
5.2 KiB
Python

"""Slack Block Kit message builders for shift schedule display."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool) -> str:
day_label = date.strftime("%a %b %-d")
if is_today:
day_label = f"*{day_label} (today)*"
if source == "available":
return f":large_yellow_circle: {day_label} — *Available*"
if source == "override":
return f":large_blue_circle: {day_label} — {name} (Ext {ext}) _[picked up]_"
return f":white_circle: {day_label} — {name} (Ext {ext})"
def build_week_schedule(schedule, start_date: datetime | None = None) -> list[dict]:
"""Build Block Kit blocks showing the two-week schedule.
Args:
schedule: ShiftSchedule instance
start_date: Monday of the first week to show. Defaults to current week's Monday.
"""
now = datetime.now(EASTERN)
if start_date is None:
start_date = now - timedelta(days=now.weekday()) # Monday of this week
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
today_str = now.strftime("%Y-%m-%d")
start_label = start_date.strftime("%b %-d")
end_label = (start_date + timedelta(days=13)).strftime("%b %-d")
# Build lines for both weeks, separated by a blank line
lines = []
open_dates = []
for i in range(14):
if i == 7:
lines.append("") # visual separator between weeks
date = start_date + timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
day_name = DAY_ORDER[i % 7]
ext, name, source = schedule.resolve_shift(date_str, day_name)
is_today = date_str == today_str
lines.append(_format_shift_line(date, ext, name, source, is_today))
if source == "available":
open_dates.append(date_str)
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"After-Hours Schedule — {start_label} to {end_label}"},
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": "\n".join(lines)},
},
]
# Add pickup buttons for open shifts (Slack max 5 per actions block)
if open_dates:
blocks.append({"type": "divider"})
elements = []
for date_str in open_dates:
dt = datetime.strptime(date_str, "%Y-%m-%d")
elements.append(
{
"type": "button",
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}"},
"action_id": f"pickup_{date_str}",
"style": "primary",
}
)
# Slack allows max 5 buttons per actions block — use multiple blocks
for chunk_start in range(0, len(elements), 5):
blocks.append({"type": "actions", "elements": elements[chunk_start:chunk_start + 5]})
return blocks
def build_shift_change_message(
user_id: str, date_str: str, action: str, ext: str, name: str
) -> list[dict]:
"""Build a channel notification for a shift change."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
day_label = dt.strftime("%A, %b %-d")
if action == "picked_up":
text = f":white_check_mark: <@{user_id}> picked up the shift for *{day_label}* (Ext {ext})"
elif action == "dropped":
text = f":warning: <@{user_id}> dropped the shift for *{day_label}* — it's now *Available*"
elif action == "swapped":
text = f":arrows_counterclockwise: <@{user_id}> swapped into the shift for *{day_label}* (Ext {ext})"
else:
text = f"Shift updated for *{day_label}*: {name} (Ext {ext})"
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_help_blocks() -> list[dict]:
"""Build help message blocks."""
text = (
"*After-Hours Shift Manager*\n\n"
"`/oncall` — Show the two-week schedule\n"
"`/oncall next` — Show the following two weeks\n"
"`/oncall pick <date>` — Pick up a shift\n"
"`/oncall drop <date>` — Drop your shift (marks it open)\n"
"`/oncall swap <date> @person` — Swap your shift with someone\n"
"`/oncall register <extension>` — Link your Slack account to your extension\n"
"`/oncall roster` — Show all registered employees\n"
"`/oncall help` — Show this help message\n\n"
"_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_"
)
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_roster_blocks(roster: list[dict]) -> list[dict]:
"""Build roster display blocks."""
if not roster:
return [{"type": "section", "text": {"type": "mrkdwn", "text": "No employees in the roster."}}]
lines = ["*On-Call Roster*\n"]
for emp in sorted(roster, key=lambda x: x["SK"]):
slack = f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
lines.append(f"Ext {emp['SK']} — {emp.get('name', 'Unknown')} ({slack})")
return [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]