Spaces:
Running
Running
| """ | |
| Per Diem / Leave Start Date Calculator (Form e49) | |
| Reserve members accrue leave at 2.5 days per month of service (per the | |
| official "Leave Accrual to Date of Separation" chart), and take that | |
| leave during the final days of the deployment, so the last day of leave | |
| equals the end date on their orders. | |
| Given the FIRST and LAST day of the deployment, this app: | |
| 1. Looks up how much leave was earned over that span, using the same | |
| day-of-month / month accrual logic as the official chart (in 0.5-day | |
| increments). | |
| 2. Truncates that to a whole number of usable days (leave can only | |
| actually be taken in whole days — e.g. 1.5 earned days means only | |
| 1 day can be taken). | |
| 3. Adds one bonus day if the deployment end date falls on a weekend or | |
| federal holiday (existing rule, unchanged from the original app). | |
| 4. Reports the leave start date, so the last day of leave still equals | |
| the deployment end date. | |
| --- IMPORTANT ASSUMPTION ABOUT THE ACCRUAL CHART --- | |
| The official chart is expressed relative to the start of the fiscal/leave | |
| year (Oct 1) and its values reset to ~0 every October. Real leave accrual | |
| does NOT actually reset mid-deployment — it just keeps accumulating at a | |
| constant 2.5 days/month. Since 6-month deployments routinely cross an | |
| Oct 1 boundary, this app reproduces the chart's exact day-of-month / | |
| month-of-year logic (verified to match the chart's printed values | |
| exactly within any single fiscal year), but does NOT hard-reset the | |
| counter at each Oct 1 — leave earned is simply | |
| (accrual value at end date) - (accrual value at start date). | |
| If your unit's actual policy is that leave literally zeroes out every | |
| Oct 1 even mid-deployment, this calculation will need to change — | |
| flag it and it can be adjusted to hard-reset instead. | |
| Usage tracking: every time someone clicks "Calculate", a timestamped | |
| row is appended to a log file in a separate Hugging Face dataset repo | |
| (Emfin2429/usesOfLeaveDates). This means the count survives this Space | |
| sleeping, restarting, or being rebuilt, since the log lives in a | |
| different repo than the Space itself. | |
| """ | |
| import gradio as gr | |
| import holidays | |
| import os | |
| import tempfile | |
| from datetime import date, datetime, timedelta | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import EntryNotFoundError | |
| US_HOLIDAYS = holidays.US() | |
| # --- Usage tracking config --- | |
| USAGE_DATASET_REPO = "Emfin2429/usesOfLeaveDates" | |
| USAGE_LOG_FILENAME = "usage_log.csv" | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| def log_usage(): | |
| """ | |
| Append a timestamped row to the usage log dataset on the Hub. | |
| Best-effort: if this fails for any reason (no token, network issue, | |
| rate limit, etc.), it should never break the calculator for the user. | |
| """ | |
| if not HF_TOKEN: | |
| print("HF_TOKEN not set — skipping usage logging.") | |
| return | |
| try: | |
| api = HfApi(token=HF_TOKEN) | |
| # Try to pull down the existing log so we can append to it. | |
| try: | |
| existing_path = hf_hub_download( | |
| repo_id=USAGE_DATASET_REPO, | |
| repo_type="dataset", | |
| filename=USAGE_LOG_FILENAME, | |
| token=HF_TOKEN, | |
| ) | |
| with open(existing_path, "r", encoding="utf-8") as f: | |
| existing_content = f.read() | |
| except EntryNotFoundError: | |
| # First-ever use: no log file yet, start a fresh one with a header. | |
| existing_content = "timestamp_utc\n" | |
| new_row = f"{datetime.utcnow().isoformat()}\n" | |
| updated_content = existing_content + new_row | |
| with tempfile.NamedTemporaryFile( | |
| mode="w", suffix=".csv", delete=False, encoding="utf-8" | |
| ) as tmp: | |
| tmp.write(updated_content) | |
| tmp_path = tmp.name | |
| api.upload_file( | |
| path_or_fileobj=tmp_path, | |
| path_in_repo=USAGE_LOG_FILENAME, | |
| repo_id=USAGE_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message="Log calculator use", | |
| ) | |
| os.remove(tmp_path) | |
| except Exception as e: | |
| # Never let logging errors break the actual calculator. | |
| print(f"Usage logging failed: {e}") | |
| def is_holiday(d: date) -> bool: | |
| """Return True if d is a US federal holiday.""" | |
| return d in US_HOLIDAYS | |
| def is_weekend(d: date) -> bool: | |
| """Return True if d is a Saturday or Sunday.""" | |
| return d.weekday() >= 5 | |
| # --- Leave accrual chart logic --- | |
| # | |
| # The official "Leave Accrual to Date of Separation" table earns 2.5 days | |
| # per calendar month, subdivided into five 0.5-day steps per month based | |
| # on which 6-day bucket the day-of-month falls into: | |
| # days 1-6 -> +0.5 (bucket 1) | |
| # days 7-12 -> +1.0 (bucket 2) | |
| # days 13-18 -> +1.5 (bucket 3) | |
| # days 19-24 -> +2.0 (bucket 4) | |
| # days 25-31 -> +2.5 (bucket 5, i.e. the full month) | |
| # | |
| # So within any month, accrued-since-start-of-month = bucket * 0.5, and | |
| # each full elapsed calendar month is worth a flat 2.5 days on top of that. | |
| def _day_bucket(day_of_month: int) -> int: | |
| """Map a day-of-month (1-31) to its 6-day accrual bucket (1-5).""" | |
| if day_of_month <= 6: | |
| return 1 | |
| elif day_of_month <= 12: | |
| return 2 | |
| elif day_of_month <= 18: | |
| return 3 | |
| elif day_of_month <= 24: | |
| return 4 | |
| else: | |
| return 5 # 25-31 (or 25-28/29/30 for shorter months) | |
| def _accrual_value(d: date) -> float: | |
| """ | |
| A running accrual "odometer" value for date d: increases by 2.5 for | |
| every full calendar month, plus the partial-month bucket amount. | |
| This is anchored to an arbitrary fixed point (not Oct 1 of any | |
| particular year), which is fine because only *differences* between | |
| two of these values are ever used — the anchor cancels out. | |
| """ | |
| absolute_month_index = d.year * 12 + (d.month - 1) | |
| return absolute_month_index * 2.5 + _day_bucket(d.day) * 0.5 | |
| def earned_leave_days(deployment_start: date, deployment_end: date) -> float: | |
| """ | |
| Days of leave earned over the deployment, per the accrual chart, | |
| rounded to the nearest 0.5 to avoid floating point drift. | |
| """ | |
| raw = _accrual_value(deployment_end) - _accrual_value(deployment_start) | |
| return round(raw * 2) / 2 | |
| def calc_leave_plan(deployment_start: date, deployment_end: date) -> dict: | |
| """ | |
| Given the deployment's first and last day, compute: | |
| - earned: raw leave earned (chart lookup, in 0.5-day increments) | |
| - usable: whole days actually usable (earned, truncated down) | |
| - exception: whether the end date is a weekend/holiday bonus day | |
| - total_days: usable days actually taken (usable + bonus day if any) | |
| - leave_start: first day of leave (last day is always deployment_end) | |
| """ | |
| earned = earned_leave_days(deployment_start, deployment_end) | |
| usable = int(earned) # whole days only; e.g. 1.5 -> 1 (earned is always >= 0) | |
| exception = is_weekend(deployment_end) or is_holiday(deployment_end) | |
| total_days = usable + (1 if exception else 0) | |
| leave_start = deployment_end - timedelta(days=max(total_days - 1, 0)) | |
| return { | |
| "earned": earned, | |
| "usable": usable, | |
| "exception": exception, | |
| "total_days": total_days, | |
| "leave_start": leave_start, | |
| "leave_end": deployment_end, | |
| } | |
| def _parse_date_input(value): | |
| """ | |
| gr.DateTime can hand back either a datetime object, a string, or a | |
| numeric timestamp depending on how the value was set, so handle all | |
| three defensively. | |
| """ | |
| if value is None: | |
| return None | |
| if isinstance(value, str): | |
| return datetime.strptime(value.split(" ")[0], "%Y-%m-%d").date() | |
| if isinstance(value, (int, float)): | |
| return datetime.fromtimestamp(value).date() | |
| return value.date() | |
| def submit_leave_dates(deployment_start_input, deployment_end_input): | |
| deployment_start = _parse_date_input(deployment_start_input) | |
| deployment_end = _parse_date_input(deployment_end_input) | |
| if deployment_start is None or deployment_end is None: | |
| return "Please select both a deployment start date and end date." | |
| if deployment_end < deployment_start: | |
| return "The deployment end date can't be before the start date." | |
| plan = calc_leave_plan(deployment_start, deployment_end) | |
| if plan["total_days"] <= 0: | |
| return ( | |
| f"Based on a deployment from {deployment_start:%B %d, %Y} to " | |
| f"{deployment_end:%B %d, %Y}, you earned {plan['earned']:g} days " | |
| "of leave — not enough to take a full day off." | |
| ) | |
| reason = [] | |
| if is_weekend(deployment_end): | |
| reason.append("your end date falls on a weekend") | |
| if is_holiday(deployment_end): | |
| reason.append("your end date is a federal holiday") | |
| extra_note = ( | |
| f" You get one extra day of leave because {' and '.join(reason)}, " | |
| f"for {plan['total_days']} total days off." | |
| if plan["exception"] | |
| else "" | |
| ) | |
| # Log this use (best-effort, won't block or break the result). | |
| log_usage() | |
| return ( | |
| f"Based on a deployment from {deployment_start:%B %d, %Y} to " | |
| f"{deployment_end:%B %d, %Y}, you earned {plan['earned']:g} days of " | |
| f"leave. Since leave can only be taken in whole days, you can use " | |
| f"{plan['usable']} day(s).{extra_note}\n\n" | |
| f"Your leave starts on {plan['leave_start']:%B %d, %Y} " | |
| f"and ends on {plan['leave_end']:%B %d, %Y}." | |
| ) | |
| with gr.Blocks(title="Leave Start Date Calculator") as app: | |
| gr.Markdown( | |
| "# Leave Start Date Calculator\n" | |
| "Enter the **first and last day of your deployment** to see how " | |
| "much leave you earned and when it starts. Your last day of leave " | |
| "will always be your deployment's end date." | |
| ) | |
| with gr.Row(): | |
| start_input = gr.DateTime( | |
| label="First day of deployment", | |
| include_time=False, | |
| type="datetime", | |
| ) | |
| end_input = gr.DateTime( | |
| label="Last day of deployment (Form e49 end date)", | |
| include_time=False, | |
| type="datetime", | |
| ) | |
| submit_btn = gr.Button("Calculate", variant="primary") | |
| output = gr.Textbox(label="Result", interactive=False, lines=4) | |
| submit_btn.click( | |
| fn=submit_leave_dates, inputs=[start_input, end_input], outputs=output | |
| ) | |
| end_input.submit( | |
| fn=submit_leave_dates, inputs=[start_input, end_input], outputs=output | |
| ) | |
| if __name__ == "__main__": | |
| app.launch() |