Merge pull request #6 from Sea-Haven-Industries/feature/weekly-pay-calculation

feat: Add weekly pay calculation for after-hours shifts
This commit is contained in:
Adam Moussa 2026-04-07 18:09:36 -04:00 • committed by GitHub
commit 57c411824c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 135 additions and 4 deletions

View file

@ -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:

View file

@ -112,6 +112,7 @@ def build_help_blocks() -> list[dict]:
"`/oncall drop <date>` — Drop your shift (marks it open)\n"
"`/oncall swap <date> @person` — Swap your shift with someone\n"
"`/oncall register <extension>` — 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:

View file

@ -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))

View file

@ -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"])}