Files changed (1) hide show
  1. app.py +61 -179
app.py CHANGED
@@ -1,36 +1,14 @@
1
  """
2
  Per Diem / Leave Start Date Calculator (Form e49)
3
 
4
- Reserve members accrue leave at 2.5 days per month of service (per the
5
- official "Leave Accrual to Date of Separation" chart), and take that
6
- leave during the final days of the deployment, so the last day of leave
7
- equals the end date on their orders.
8
-
9
- Given the FIRST and LAST day of the deployment, this app:
10
- 1. Looks up how much leave was earned over that span, using the same
11
- day-of-month / month accrual logic as the official chart (in 0.5-day
12
- increments).
13
- 2. Truncates that to a whole number of usable days (leave can only
14
- actually be taken in whole days — e.g. 1.5 earned days means only
15
- 1 day can be taken).
16
- 3. Adds one bonus day if the deployment end date falls on a weekend or
17
- federal holiday (existing rule, unchanged from the original app).
18
- 4. Reports the leave start date, so the last day of leave still equals
19
- the deployment end date.
20
-
21
- --- IMPORTANT ASSUMPTION ABOUT THE ACCRUAL CHART ---
22
- The official chart is expressed relative to the start of the fiscal/leave
23
- year (Oct 1) and its values reset to ~0 every October. Real leave accrual
24
- does NOT actually reset mid-deployment — it just keeps accumulating at a
25
- constant 2.5 days/month. Since 6-month deployments routinely cross an
26
- Oct 1 boundary, this app reproduces the chart's exact day-of-month /
27
- month-of-year logic (verified to match the chart's printed values
28
- exactly within any single fiscal year), but does NOT hard-reset the
29
- counter at each Oct 1 — leave earned is simply
30
- (accrual value at end date) - (accrual value at start date).
31
- If your unit's actual policy is that leave literally zeroes out every
32
- Oct 1 even mid-deployment, this calculation will need to change —
33
- flag it and it can be adjusted to hard-reset instead.
34
 
35
  Usage tracking: every time someone clicks "Calculate", a timestamped
36
  row is appended to a log file in a separate Hugging Face dataset repo
@@ -105,180 +83,84 @@ def log_usage():
105
  print(f"Usage logging failed: {e}")
106
 
107
 
108
- def is_holiday(d: date) -> bool:
109
- """Return True if d is a US federal holiday."""
110
- return d in US_HOLIDAYS
111
-
112
-
113
- def is_weekend(d: date) -> bool:
114
- """Return True if d is a Saturday or Sunday."""
115
- return d.weekday() >= 5
116
-
117
-
118
- # --- Leave accrual chart logic ---
119
- #
120
- # The official "Leave Accrual to Date of Separation" table earns 2.5 days
121
- # per calendar month, subdivided into five 0.5-day steps per month based
122
- # on which 6-day bucket the day-of-month falls into:
123
- # days 1-6 -> +0.5 (bucket 1)
124
- # days 7-12 -> +1.0 (bucket 2)
125
- # days 13-18 -> +1.5 (bucket 3)
126
- # days 19-24 -> +2.0 (bucket 4)
127
- # days 25-31 -> +2.5 (bucket 5, i.e. the full month)
128
- #
129
- # So within any month, accrued-since-start-of-month = bucket * 0.5, and
130
- # each full elapsed calendar month is worth a flat 2.5 days on top of that.
131
-
132
-
133
- def _day_bucket(day_of_month: int) -> int:
134
- """Map a day-of-month (1-31) to its 6-day accrual bucket (1-5)."""
135
- if day_of_month <= 6:
136
- return 1
137
- elif day_of_month <= 12:
138
- return 2
139
- elif day_of_month <= 18:
140
- return 3
141
- elif day_of_month <= 24:
142
- return 4
143
- else:
144
- return 5 # 25-31 (or 25-28/29/30 for shorter months)
145
-
146
-
147
- def _accrual_value(d: date) -> float:
148
- """
149
- A running accrual "odometer" value for date d: increases by 2.5 for
150
- every full calendar month, plus the partial-month bucket amount.
151
- This is anchored to an arbitrary fixed point (not Oct 1 of any
152
- particular year), which is fine because only *differences* between
153
- two of these values are ever used — the anchor cancels out.
154
- """
155
- absolute_month_index = d.year * 12 + (d.month - 1)
156
- return absolute_month_index * 2.5 + _day_bucket(d.day) * 0.5
157
-
158
-
159
- def earned_leave_days(deployment_start: date, deployment_end: date) -> float:
160
- """
161
- Days of leave earned over the deployment, per the accrual chart,
162
- rounded to the nearest 0.5 to avoid floating point drift.
163
- """
164
- raw = _accrual_value(deployment_end) - _accrual_value(deployment_start)
165
- return round(raw * 2) / 2
166
-
167
-
168
- def calc_leave_plan(deployment_start: date, deployment_end: date) -> dict:
169
- """
170
- Given the deployment's first and last day, compute:
171
- - earned: raw leave earned (chart lookup, in 0.5-day increments)
172
- - usable: whole days actually usable (earned, truncated down)
173
- - exception: whether the end date is a weekend/holiday bonus day
174
- - total_days: usable days actually taken (usable + bonus day if any)
175
- - leave_start: first day of leave (last day is always deployment_end)
176
- """
177
- earned = earned_leave_days(deployment_start, deployment_end)
178
- usable = int(earned) # whole days only; e.g. 1.5 -> 1 (earned is always >= 0)
179
-
180
- exception = is_weekend(deployment_end) or is_holiday(deployment_end)
181
- total_days = usable + (1 if exception else 0)
182
 
183
- leave_start = deployment_end - timedelta(days=max(total_days - 1, 0))
184
 
185
- return {
186
- "earned": earned,
187
- "usable": usable,
188
- "exception": exception,
189
- "total_days": total_days,
190
- "leave_start": leave_start,
191
- "leave_end": deployment_end,
192
- }
193
 
194
 
195
- def _parse_date_input(value):
196
  """
197
- gr.DateTime can hand back either a datetime object, a string, or a
198
- numeric timestamp depending on how the value was set, so handle all
199
- three defensively.
200
  """
201
- if value is None:
202
- return None
203
- if isinstance(value, str):
204
- return datetime.strptime(value.split(" ")[0], "%Y-%m-%d").date()
205
- if isinstance(value, (int, float)):
206
- return datetime.fromtimestamp(value).date()
207
- return value.date()
208
-
209
-
210
- def submit_leave_dates(deployment_start_input, deployment_end_input):
211
- deployment_start = _parse_date_input(deployment_start_input)
212
- deployment_end = _parse_date_input(deployment_end_input)
213
-
214
- if deployment_start is None or deployment_end is None:
215
- return "Please select both a deployment start date and end date."
216
-
217
- if deployment_end < deployment_start:
218
- return "The deployment end date can't be before the start date."
219
-
220
- plan = calc_leave_plan(deployment_start, deployment_end)
 
 
 
 
 
 
221
 
222
- if plan["total_days"] <= 0:
223
- return (
224
- f"Based on a deployment from {deployment_start:%B %d, %Y} to "
225
- f"{deployment_end:%B %d, %Y}, you earned {plan['earned']:g} days "
226
- "of leave — not enough to take a full day off."
227
- )
228
 
229
  reason = []
230
- if is_weekend(deployment_end):
231
  reason.append("your end date falls on a weekend")
232
- if is_holiday(deployment_end):
233
  reason.append("your end date is a federal holiday")
234
- extra_note = (
235
- f" You get one extra day of leave because {' and '.join(reason)}, "
236
- f"for {plan['total_days']} total days off."
237
- if plan["exception"]
238
- else ""
239
- )
240
 
241
  # Log this use (best-effort, won't block or break the result).
242
  log_usage()
243
 
244
  return (
245
- f"Based on a deployment from {deployment_start:%B %d, %Y} to "
246
- f"{deployment_end:%B %d, %Y}, you earned {plan['earned']:g} days of "
247
- f"leave. Since leave can only be taken in whole days, you can use "
248
- f"{plan['usable']} day(s).{extra_note}\n\n"
249
- f"Your leave starts on {plan['leave_start']:%B %d, %Y} "
250
- f"and ends on {plan['leave_end']:%B %d, %Y}."
251
  )
252
 
253
 
254
  with gr.Blocks(title="Leave Start Date Calculator") as app:
255
  gr.Markdown(
256
  "# Leave Start Date Calculator\n"
257
- "Enter the **first and last day of your deployment** to see how "
258
- "much leave you earned and when it starts. Your last day of leave "
259
- "will always be your deployment's end date."
 
 
 
 
260
  )
261
- with gr.Row():
262
- start_input = gr.DateTime(
263
- label="First day of deployment",
264
- include_time=False,
265
- type="datetime",
266
- )
267
- end_input = gr.DateTime(
268
- label="Last day of deployment (Form e49 end date)",
269
- include_time=False,
270
- type="datetime",
271
- )
272
  submit_btn = gr.Button("Calculate", variant="primary")
273
- output = gr.Textbox(label="Result", interactive=False, lines=4)
274
 
275
- submit_btn.click(
276
- fn=submit_leave_dates, inputs=[start_input, end_input], outputs=output
277
- )
278
- end_input.submit(
279
- fn=submit_leave_dates, inputs=[start_input, end_input], outputs=output
280
- )
281
 
282
 
283
  if __name__ == "__main__":
284
- app.launch()
 
1
  """
2
  Per Diem / Leave Start Date Calculator (Form e49)
3
 
4
+ Reserve members accrue leave (typically 15 days for a 6-month deployment)
5
+ and take it during the last 15 days of the deployment, so the last day of
6
+ leave equals the end date on their orders. Given that end date, this app
7
+ tells them when their leave starts.
8
+
9
+ Rule: leave is 15 days inclusive of start and end date.
10
+ If the end date falls on a weekend or federal holiday, the member gets
11
+ one extra day of leave (so the start date shifts one day earlier).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  Usage tracking: every time someone clicks "Calculate", a timestamped
14
  row is appended to a log file in a separate Hugging Face dataset repo
 
83
  print(f"Usage logging failed: {e}")
84
 
85
 
86
+ def is_holiday(end_date: date) -> bool:
87
+ """Return True if end_date is a US federal holiday."""
88
+ return end_date in US_HOLIDAYS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
 
90
 
91
+ def is_weekend(end_date: date) -> bool:
92
+ """Return True if end_date is a Saturday or Sunday."""
93
+ return end_date.weekday() >= 5
 
 
 
 
 
94
 
95
 
96
+ def fifteen_days_prior(end_date: date, exception: bool) -> date:
97
  """
98
+ Return the date 15 days (inclusive) before end_date.
99
+ If `exception` is True (end date is a weekend/holiday), the member
100
+ gets one extra day of leave, so we go back 15 days instead of 14.
101
  """
102
+ if exception:
103
+ return end_date - timedelta(days=15)
104
+ return end_date - timedelta(days=14)
105
+
106
+
107
+ def calc_leave_dates(end_date: date) -> tuple[date, date]:
108
+ """Determine the leave start date given the leave end date."""
109
+ exception = is_weekend(end_date) or is_holiday(end_date)
110
+ start_date = fifteen_days_prior(end_date, exception)
111
+ return start_date, end_date
112
+
113
+
114
+ def submit_leave_date(end_of_leave):
115
+ if end_of_leave is None:
116
+ return "Please select a date."
117
+
118
+ # gr.DateTime can hand back either a datetime object or a string
119
+ # depending on how the value was set, so handle both defensively.
120
+ if isinstance(end_of_leave, str):
121
+ end_of_leave = datetime.strptime(
122
+ end_of_leave.split(" ")[0], "%Y-%m-%d"
123
+ ).date()
124
+ elif isinstance(end_of_leave, (int, float)):
125
+ end_of_leave = datetime.fromtimestamp(end_of_leave).date()
126
+ else:
127
+ end_of_leave = end_of_leave.date()
128
 
129
+ start_date, end_date = calc_leave_dates(end_of_leave)
 
 
 
 
 
130
 
131
  reason = []
132
+ if is_weekend(end_date):
133
  reason.append("your end date falls on a weekend")
134
+ if is_holiday(end_date):
135
  reason.append("your end date is a federal holiday")
136
+ extra_note = f" (You get an extra day of leave because {' and '.join(reason)}.)" if reason else ""
 
 
 
 
 
137
 
138
  # Log this use (best-effort, won't block or break the result).
139
  log_usage()
140
 
141
  return (
142
+ f"Your leave starts on {start_date:%B %d, %Y} "
143
+ f"and ends on {end_date:%B %d, %Y}.{extra_note}"
 
 
 
 
144
  )
145
 
146
 
147
  with gr.Blocks(title="Leave Start Date Calculator") as app:
148
  gr.Markdown(
149
  "# Leave Start Date Calculator\n"
150
+ "Enter the **end date of your deployment (Form e49 end date)** "
151
+ "to find out when your 15 days of leave will start."
152
+ )
153
+ date_input = gr.DateTime(
154
+ label="When is the end of your leave?",
155
+ include_time=False,
156
+ type="datetime",
157
  )
 
 
 
 
 
 
 
 
 
 
 
158
  submit_btn = gr.Button("Calculate", variant="primary")
159
+ output = gr.Textbox(label="Result", interactive=False)
160
 
161
+ submit_btn.click(fn=submit_leave_date, inputs=date_input, outputs=output)
162
+ date_input.submit(fn=submit_leave_date, inputs=date_input, outputs=output)
 
 
 
 
163
 
164
 
165
  if __name__ == "__main__":
166
+ app.launch()