Merge pull request #9 from Sea-Haven-Industries/feature/weekend-day-shifts

feat: Add weekend day shifts and exclude ext 100 from pay
This commit is contained in:
Adam Moussa 2026-04-07 18:23:16 -04:00 • committed by GitHub
commit bd3d27f774
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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): def handle_pickup_button(ack, body, client):
ack() ack()
action_id = body["actions"][0]["action_id"] 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"] user_id = body["user"]["id"]
channel_id = body["channel"]["id"] channel_id = body["channel"]["id"]
@ -137,9 +145,9 @@ def create_app(bot_token: str, signing_secret: str) -> App:
) )
return 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"]) invoke_3cx_scheduler(employee["extension"])
blocks = build_shift_change_message( blocks = build_shift_change_message(

View file

@ -6,10 +6,13 @@ from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 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") day_label = date.strftime("%a %b %-d")
if shift_type == "day":
day_label += " (Day 8a–5p)"
if is_today: if is_today:
day_label = f"*{day_label} (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 # Build lines for both weeks, separated by a blank line
lines = [] lines = []
open_dates = [] open_shifts = [] # list of (date_str, shift_type) for pickup buttons
for i in range(14): for i in range(14):
if i == 7: if i == 7:
lines.append("") # visual separator between weeks lines.append("") # visual separator between weeks
date = start_date + timedelta(days=i) date = start_date + timedelta(days=i)
date_str = date.strftime("%Y-%m-%d") date_str = date.strftime("%Y-%m-%d")
day_name = DAY_ORDER[i % 7] day_name = DAY_ORDER[i % 7]
ext, name, source = schedule.resolve_shift(date_str, day_name)
is_today = date_str == today_str 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)) lines.append(_format_shift_line(date, ext, name, source, is_today))
if source == "available": if source == "available":
open_dates.append(date_str) open_shifts.append((date_str, "night"))
blocks = [ 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) # Add pickup buttons for open shifts (Slack max 5 per actions block)
if open_dates: if open_shifts:
blocks.append({"type": "divider"}) blocks.append({"type": "divider"})
elements = [] elements = []
for date_str in open_dates: for date_str, shift_type in open_shifts:
dt = datetime.strptime(date_str, "%Y-%m-%d") 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( elements.append(
{ {
"type": "button", "type": "button",
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}"}, "text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}"},
"action_id": f"pickup_{date_str}", "action_id": f"pickup_{date_str}{action_suffix}",
"style": "primary", "style": "primary",
} }
) )

View file

@ -57,51 +57,57 @@ class ShiftSchedule:
# ── Overrides ─────────────────────────────────────────────────────── # ── Overrides ───────────────────────────────────────────────────────
def get_override(self, date_str: str) -> dict | None: def get_override(self, date_str: str, shift_type: str = "night") -> dict | None:
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": date_str}) 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") 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( self.table.put_item(
Item={ Item={
"PK": "OVERRIDE", "PK": "OVERRIDE",
"SK": date_str, "SK": sk,
"extension": extension, "extension": extension,
"name": name, "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( self.table.put_item(
Item={ Item={
"PK": "OVERRIDE", "PK": "OVERRIDE",
"SK": date_str, "SK": sk,
"extension": "OPEN", "extension": "OPEN",
"name": "Open", "name": "Open",
} }
) )
def remove_override(self, date_str: str) -> None: def remove_override(self, date_str: str, shift_type: str = "night") -> None:
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": date_str}) sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": sk})
# ── Resolution ────────────────────────────────────────────────────── # ── Resolution ──────────────────────────────────────────────────────
def resolve_shift(self, date_str: str, day_name: str) -> tuple[str, str, str]: 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. """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'. source is one of: 'override', 'available', 'weekly'.
'available' means no one is assigned — the fallback extension handles '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. 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:
if override["extension"] == "OPEN": if override["extension"] == "OPEN":
return (FALLBACK_EXTENSION, "Available", "available") return (FALLBACK_EXTENSION, "Available", "available")
return (override["extension"], override["name"], "override") return (override["extension"], override["name"], "override")
weekly = self.get_weekly_schedule() weekly = self.get_weekly_schedule()
if day_name in weekly: weekly_key = f"{day_name}-Day" if shift_type == "day" else day_name
item = weekly[day_name] if weekly_key in weekly:
item = weekly[weekly_key]
if item["extension"] == FALLBACK_EXTENSION: if item["extension"] == FALLBACK_EXTENSION:
return (FALLBACK_EXTENSION, "Available", "available") return (FALLBACK_EXTENSION, "Available", "available")
return (item["extension"], item.get("name", "Unknown"), "weekly") return (item["extension"], item.get("name", "Unknown"), "weekly")

View file

@ -10,7 +10,7 @@ import boto3
from slack_sdk import WebClient from slack_sdk import WebClient
from src.blocks import build_pay_summary_blocks, build_week_schedule 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 = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -20,6 +20,35 @@ EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 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: def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
"""Calculate pay for a Mon–Sun week. Returns pay record dict.""" """Calculate pay for a Mon–Sun week. Returns pay record dict."""
default_rate = schedule.get_shift_rate() 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): for i in range(7):
date = week_start + timedelta(days=i) date = week_start + timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
day_name = DAY_ORDER[i] day_name = DAY_ORDER[i]
ext, name, source = schedule.resolve_shift(date_str, day_name)
if source == "available": # Weekend day shifts (8am–5pm)
continue if day_name in WEEKEND_DAYS:
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "day")
rate = schedule.get_shift_rate(ext) # After-hours (night) shift
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
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
return { return {
"week_start": week_start.strftime("%Y-%m-%d"), "week_start": week_start.strftime("%Y-%m-%d"),