mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-03 01:03:20 +00:00
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:
commit
bd3d27f774
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):
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue