diff --git a/src/app.py b/src/app.py index 77b17f4..6adbfa0 100644 --- a/src/app.py +++ b/src/app.py @@ -12,6 +12,7 @@ from slack_bolt import App from src.blocks import ( build_help_blocks, + build_pay_summary_blocks, build_roster_blocks, build_shift_change_message, build_week_schedule, @@ -102,6 +103,8 @@ def create_app(bot_token: str, signing_secret: str) -> App: respond(blocks=build_help_blocks()) elif text == "roster": respond(blocks=build_roster_blocks(schedule.get_roster())) + elif text == "pay": + _show_pay(respond, schedule) elif text.startswith("register"): _handle_register(respond, schedule, user_id, text) elif text.startswith("pick"): @@ -156,6 +159,24 @@ def create_app(bot_token: str, signing_secret: str) -> App: blocks = build_week_schedule(schedule, start_date=next_start) respond(blocks=blocks) + def _show_pay(respond, schedule): + now = datetime.now(EASTERN) + # Show last completed week's pay (previous Monday–Sunday) + this_monday = now - timedelta(days=now.weekday()) + prev_monday = this_monday - timedelta(days=7) + week_key = prev_monday.strftime("%Y-%m-%d") + + pay_record = schedule.get_pay_record(week_key) + if pay_record and pay_record.get("breakdown"): + prev_sunday = prev_monday + timedelta(days=6) + week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}" + blocks = build_pay_summary_blocks( + week_label, pay_record["breakdown"], pay_record["totals"], float(pay_record["rate"]) + ) + respond(blocks=blocks) + else: + respond(text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}.") + def _handle_register(respond, schedule, user_id, text): parts = text.split() if len(parts) < 2: diff --git a/src/blocks.py b/src/blocks.py index cd176c5..e54a771 100644 --- a/src/blocks.py +++ b/src/blocks.py @@ -112,6 +112,7 @@ def build_help_blocks() -> list[dict]: "`/oncall drop ` — Drop your shift (marks it open)\n" "`/oncall swap @person` — Swap your shift with someone\n" "`/oncall register ` — Link your Slack account to your extension\n" + "`/oncall pay` — Show last week's pay summary\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_" @@ -119,6 +120,36 @@ def build_help_blocks() -> list[dict]: return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] +def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict], rate: float) -> list[dict]: + """Build Block Kit blocks for the weekly pay summary. + + Args: + week_label: e.g. "Mar 30 to Apr 5" + pay_breakdown: list of {date, day, name, extension, rate} per shift + total_by_person: {name: {shifts: int, total: float, extension: str}} + rate: flat rate per shift + """ + lines = [f"_Rate: ${rate:.2f} per shift_\n"] + for entry in pay_breakdown: + lines.append(f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']})") + + lines.append("\n*Totals:*") + for name, info in sorted(total_by_person.items()): + lines.append(f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''} — *${info['total']:.2f}*") + + blocks = [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"After-Hours Pay — {week_label}"}, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "\n".join(lines)}, + }, + ] + return blocks + + def build_roster_blocks(roster: list[dict]) -> list[dict]: """Build roster display blocks.""" if not roster: diff --git a/src/schedule.py b/src/schedule.py index aaf38e7..148e26e 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -108,8 +108,26 @@ class ShiftSchedule: return (FALLBACK_EXTENSION, "Available", "available") + # ── Pay records ───────────────────────────────────────────────────── + + def get_pay_record(self, week_key: str) -> dict | None: + """Get a pay record by week key (e.g. '2026-04-06').""" + resp = self.table.get_item(Key={"PK": "PAY", "SK": week_key}) + return resp.get("Item") + + def save_pay_record(self, week_key: str, record: dict) -> None: + """Save a weekly pay summary. week_key is the Monday date string.""" + self.table.put_item( + Item={"PK": "PAY", "SK": week_key, **record} + ) + # ── Config ────────────────────────────────────────────────────────── def get_config(self) -> dict: resp = self.table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"}) return resp.get("Item", {}) + + def get_shift_rate(self) -> float: + """Get the flat per-shift pay rate from config.""" + config = self.get_config() + return float(config.get("shift_rate", 0)) diff --git a/src/weekly_post.py b/src/weekly_post.py index 0f50c7d..965d095 100644 --- a/src/weekly_post.py +++ b/src/weekly_post.py @@ -1,4 +1,5 @@ -"""Lambda handler — posts the weekly on-call schedule to Slack every Monday at 7am ET.""" +"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary +to Slack every Monday at 7am ET.""" import logging import os @@ -8,7 +9,7 @@ from zoneinfo import ZoneInfo import boto3 from slack_sdk import WebClient -from src.blocks import build_week_schedule +from src.blocks import build_pay_summary_blocks, build_week_schedule from src.schedule import ShiftSchedule logger = logging.getLogger() @@ -16,6 +17,45 @@ logger.setLevel(logging.INFO) EASTERN = ZoneInfo("America/New_York") +DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + +def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict: + """Calculate pay for a Mon–Sun week. Returns pay record dict.""" + rate = schedule.get_shift_rate() + breakdown = [] + totals = {} + + 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 + + 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} + totals[name]["shifts"] += 1 + totals[name]["total"] += rate + + return { + "week_start": week_start.strftime("%Y-%m-%d"), + "rate": str(rate), + "breakdown": breakdown, + "totals": totals, + } + def handler(event, context): now = datetime.now(EASTERN) @@ -36,7 +76,28 @@ def handler(event, context): schedule = ShiftSchedule() slack = WebClient(token=bot_token) - # Show two weeks starting from today (Monday) + # --- Previous week's pay summary --- + prev_monday = now - timedelta(days=7) + prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0) + pay_record = _calculate_weekly_pay(schedule, prev_monday) + + if pay_record["breakdown"]: + week_key = prev_monday.strftime("%Y-%m-%d") + schedule.save_pay_record(week_key, pay_record) + + prev_sunday = prev_monday + timedelta(days=6) + week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}" + pay_blocks = build_pay_summary_blocks( + week_label, pay_record["breakdown"], pay_record["totals"], float(pay_record["rate"]) + ) + slack.chat_postMessage( + channel=channel_id, + blocks=pay_blocks, + text=f"After-Hours Pay — {week_label}", + ) + logger.info("Posted pay summary for week of %s", week_key) + + # --- Two-week schedule --- blocks = build_week_schedule(schedule, start_date=now) end_date = now + timedelta(days=13) @@ -47,4 +108,4 @@ def handler(event, context): ) logger.info("Posted weekly schedule to channel %s", channel_id) - return {"posted": True, "channel": channel_id} + return {"posted": True, "channel": channel_id, "pay_calculated": bool(pay_record["breakdown"])}