diff --git a/src/app.py b/src/app.py index 962d263..44f96ee 100644 --- a/src/app.py +++ b/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( diff --git a/src/blocks.py b/src/blocks.py index 40882c7..4ce3234 100644 --- a/src/blocks.py +++ b/src/blocks.py @@ -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", } ) diff --git a/src/schedule.py b/src/schedule.py index 84d4450..af30fe9 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -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") diff --git a/src/weekly_post.py b/src/weekly_post.py index 7d361f0..fa7f1e2 100644 --- a/src/weekly_post.py +++ b/src/weekly_post.py @@ -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"),