Add weekend day shifts and exclude fallback ext from pay

Saturdays and Sundays now have two shift slots: a day shift (8am–5pm)
and the existing after-hours shift. Extension 100 (fallback) is
excluded from pay calculations. Pickup buttons and schedule display
updated to handle both shift types.
This commit is contained in:
Adam Moussa 2026-04-07 18:22:56 -04:00
parent 9782cd98ee
commit 7eb0443866
4 changed files with 87 additions and 44 deletions

View file

@ -124,7 +124,15 @@ def create_app(bot_token: str, signing_secret: str) -> App:
def handle_pickup_button(ack, body, client):
ack()
action_id = body["actions"][0]["action_id"]
date_str = action_id.replace("pickup_", "")
# Parse action_id: pickup_2026-04-12 or pickup_2026-04-12_day
remainder = action_id.replace("pickup_", "")
if remainder.endswith("_day"):
date_str = remainder[:-4]
shift_type = "day"
else:
date_str = remainder
shift_type = "night"
user_id = body["user"]["id"]
channel_id = body["channel"]["id"]
@ -137,9 +145,9 @@ def create_app(bot_token: str, signing_secret: str) -> App:
)
return
schedule.set_override(date_str, employee["extension"], employee["name"])
schedule.set_override(date_str, employee["extension"], employee["name"], shift_type)
if is_today(date_str):
if is_today(date_str) and shift_type == "night":
invoke_3cx_scheduler(employee["extension"])
blocks = build_shift_change_message(

View file

@ -6,10 +6,13 @@ from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
WEEKEND_DAYS = {"Saturday", "Sunday"}
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool) -> str:
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool, shift_type: str = "night") -> str:
day_label = date.strftime("%a %b %-d")
if shift_type == "day":
day_label += " (Day 8a–5p)"
if is_today:
day_label = f"*{day_label} (today)*"
@ -38,18 +41,27 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
# Build lines for both weeks, separated by a blank line
lines = []
open_dates = []
open_shifts = [] # list of (date_str, shift_type) for pickup buttons
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
# Weekend day shift (8am–5pm)
if day_name in WEEKEND_DAYS:
ext, name, source = schedule.resolve_shift(date_str, day_name, "day")
lines.append(_format_shift_line(date, ext, name, source, is_today, "day"))
if source == "available":
open_shifts.append((date_str, "day"))
# After-hours (night) shift
ext, name, source = schedule.resolve_shift(date_str, day_name)
lines.append(_format_shift_line(date, ext, name, source, is_today))
if source == "available":
open_dates.append(date_str)
open_shifts.append((date_str, "night"))
blocks = [
{
@ -63,16 +75,18 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
]
# Add pickup buttons for open shifts (Slack max 5 per actions block)
if open_dates:
if open_shifts:
blocks.append({"type": "divider"})
elements = []
for date_str in open_dates:
for date_str, shift_type in open_shifts:
dt = datetime.strptime(date_str, "%Y-%m-%d")
suffix = " (Day)" if shift_type == "day" else ""
action_suffix = "_day" if shift_type == "day" else ""
elements.append(
{
"type": "button",
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}"},
"action_id": f"pickup_{date_str}",
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}"},
"action_id": f"pickup_{date_str}{action_suffix}",
"style": "primary",
}
)

View file

@ -57,51 +57,57 @@ class ShiftSchedule:
# ── Overrides ───────────────────────────────────────────────────────
def get_override(self, date_str: str) -> dict | None:
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": date_str})
def get_override(self, date_str: str, shift_type: str = "night") -> dict | None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": sk})
return resp.get("Item")
def set_override(self, date_str: str, extension: str, name: str) -> None:
def set_override(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
"PK": "OVERRIDE",
"SK": date_str,
"SK": sk,
"extension": extension,
"name": name,
}
)
def mark_open(self, date_str: str) -> None:
def mark_open(self, date_str: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
"PK": "OVERRIDE",
"SK": date_str,
"SK": sk,
"extension": "OPEN",
"name": "Open",
}
)
def remove_override(self, date_str: str) -> None:
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": date_str})
def remove_override(self, date_str: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": sk})
# ── Resolution ──────────────────────────────────────────────────────
def resolve_shift(self, date_str: str, day_name: str) -> tuple[str, str, str]:
"""Return (extension, name, source) for a given date.
def resolve_shift(self, date_str: str, day_name: str, shift_type: str = "night") -> tuple[str, str, str]:
"""Return (extension, name, source) for a given date and shift type.
shift_type: 'night' (after-hours) or 'day' (weekend 8am-5pm)
source is one of: 'override', 'available', 'weekly'.
'available' means no one is assigned — the fallback extension handles
it silently on the 3CX side, but users just see it as open for pickup.
"""
override = self.get_override(date_str)
override = self.get_override(date_str, shift_type)
if override:
if override["extension"] == "OPEN":
return (FALLBACK_EXTENSION, "Available", "available")
return (override["extension"], override["name"], "override")
weekly = self.get_weekly_schedule()
if day_name in weekly:
item = weekly[day_name]
weekly_key = f"{day_name}-Day" if shift_type == "day" else day_name
if weekly_key in weekly:
item = weekly[weekly_key]
if item["extension"] == FALLBACK_EXTENSION:
return (FALLBACK_EXTENSION, "Available", "available")
return (item["extension"], item.get("name", "Unknown"), "weekly")

View file

@ -10,7 +10,7 @@ import boto3
from slack_sdk import WebClient
from src.blocks import build_pay_summary_blocks, build_week_schedule
from src.schedule import ShiftSchedule
from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@ -20,6 +20,35 @@ EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
WEEKEND_DAYS = {"Saturday", "Sunday"}
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
"""Resolve a shift and add it to the pay breakdown if assigned."""
date_str = date.strftime("%Y-%m-%d")
ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type)
if source == "available" or ext == FALLBACK_EXTENSION:
return
rate = schedule.get_shift_rate(ext)
label_suffix = " (Day)" if shift_type == "day" else ""
breakdown.append({
"date": date_str,
"date_label": date.strftime("%b %-d"),
"day": f"{day_name[:3]}{label_suffix}",
"name": name,
"extension": ext,
"rate": rate,
})
if name not in totals:
totals[name] = {"shifts": 0, "total": 0.0, "extension": ext, "rate": rate}
totals[name]["shifts"] += 1
totals[name]["total"] += rate
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
default_rate = schedule.get_shift_rate()
@ -28,28 +57,14 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
for i in range(7):
date = week_start + timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
day_name = DAY_ORDER[i]
ext, name, source = schedule.resolve_shift(date_str, day_name)
if source == "available":
continue
# Weekend day shifts (8am–5pm)
if day_name in WEEKEND_DAYS:
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "day")
rate = schedule.get_shift_rate(ext)
breakdown.append({
"date": date_str,
"date_label": date.strftime("%b %-d"),
"day": day_name[:3],
"name": name,
"extension": ext,
"rate": rate,
})
if name not in totals:
totals[name] = {"shifts": 0, "total": 0.0, "extension": ext, "rate": rate}
totals[name]["shifts"] += 1
totals[name]["total"] += rate
# After-hours (night) shift
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
return {
"week_start": week_start.strftime("%Y-%m-%d"),