mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
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:
parent
9782cd98ee
commit
7eb0443866
4 changed files with 87 additions and 44 deletions
14
src/app.py
14
src/app.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue