<?php
/**
* Plugin Name: PTO Calculator
* Description: Paid Time Off calculator with a PHP backend (REST API) and a [pto_calculator] shortcode for the frontend form.
* Version: 1.0.0
* Author: You
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
/* =========================================================
* 1. BACKEND: REST API endpoint that does the PTO math
* ========================================================= */
add_action( 'rest_api_init', function () {
register_rest_route( 'pto/v1', '/calculate', array(
'methods' => 'POST',
'callback' => 'pto_calculate_handler',
'permission_callback' => '__return_true', // public endpoint; add auth here if needed
) );
} );
function pto_calculate_handler( WP_REST_Request $request ) {
$params = $request->get_json_params();
$start_raw = isset( $params['start'] ) ? sanitize_text_field( $params['start'] ) : '';
$end_raw = isset( $params['end'] ) ? sanitize_text_field( $params['end'] ) : '';
$prev_balance = isset( $params['previousBalance'] ) ? floatval( $params['previousBalance'] ) : 0;
$accrue_rate = isset( $params['accrueRate'] ) ? floatval( $params['accrueRate'] ) : 0;
$every = isset( $params['every'] ) ? sanitize_text_field( $params['every'] ) : 'hour'; // hour | week | month
$hours_per_day = isset( $params['hoursPerDay'] ) ? floatval( $params['hoursPerDay'] ) : 0;
$days_off = isset( $params['daysOff'] ) ? floatval( $params['daysOff'] ) : 0;
$max_hours = ( isset( $params['maxHours'] ) && $params['maxHours'] !== '' ) ? floatval( $params['maxHours'] ) : null;
$work_days = isset( $params['workDays'] ) && is_array( $params['workDays'] ) ? array_map( 'intval', $params['workDays'] ) : array( 1, 2, 3, 4, 5 );
// --- Validation ---
if ( empty( $start_raw ) || empty( $end_raw ) ) {
return new WP_Error( 'pto_missing_dates', 'Start and end date are required.', array( 'status' => 400 ) );
}
try {
$start = new DateTime( $start_raw );
$end = new DateTime( $end_raw );
} catch ( Exception $e ) {
return new WP_Error( 'pto_bad_date', 'Invalid date format.', array( 'status' => 400 ) );
}
if ( $end < $start ) {
return new WP_Error( 'pto_bad_range', 'End date must be on or after start date.', array( 'status' => 400 ) );
}
if ( ! in_array( $every, array( 'hour', 'week', 'month' ), true ) ) {
return new WP_Error( 'pto_bad_every', 'Invalid accrual unit.', array( 'status' => 400 ) );
}
// --- Walk the date range, same logic as the frontend version ---
$work_day_count = 0;
$week_keys = array();
$month_keys = array();
$cursor = clone $start;
$one_day = new DateInterval( 'P1D' );
$work_days_set = array_flip( $work_days ); // for fast lookup
while ( $cursor <= $end ) {
$dow = (int) $cursor->format( 'w' ); // 0 (Sun) - 6 (Sat)
if ( isset( $work_days_set[ $dow ] ) ) {
$work_day_count++;
$week_start = clone $cursor;
$week_start->modify( '-' . $dow . ' days' );
$week_keys[ $week_start->format( 'Y-m-d' ) ] = true;
}
$month_keys[ $cursor->format( 'Y-n' ) ] = true;
$cursor->add( $one_day );
}
$weeks_worked = count( $week_keys );
$months_worked = count( $month_keys );
$effective_work_days = max( $work_day_count - $days_off, 0 );
$accrued = 0;
$note = '';
switch ( $every ) {
case 'hour':
$hours_worked = $effective_work_days * $hours_per_day;
$accrued = $hours_worked * $accrue_rate;
$note = sprintf( '%s work day(s) x %s hrs/day = %s hours worked.',
pto_fmt( $effective_work_days ), pto_fmt( $hours_per_day ), pto_fmt( $hours_worked ) );
break;
case 'week':
$accrued = $weeks_worked * $accrue_rate;
$note = sprintf( '%s week(s) in range.', pto_fmt( $weeks_worked ) );
break;
case 'month':
$accrued = $months_worked * $accrue_rate;
$note = sprintf( '%s month(s) in range.', pto_fmt( $months_worked ) );
break;
}
$total = $prev_balance + $accrued;
$capped = false;
if ( $max_hours !== null && $total > $max_hours ) {
$total = $max_hours;
$capped = true;
}
return new WP_REST_Response( array(
'accrued' => round( $accrued, 2 ),
'previousBalance' => round( $prev_balance, 2 ),
'total' => round( $total, 2 ),
'capped' => $capped,
'note' => $note . ( $capped ? ' Capped at max hours limit.' : '' ),
), 200 );
}
function pto_fmt( $n ) {
return rtrim( rtrim( number_format( (float) $n, 2, '.', '' ), '0' ), '.' ) ?: '0';
}
/* =========================================================
* 2. FRONTEND: [pto_calculator] shortcode
* Renders the form and calls the REST endpoint above via fetch()
* ========================================================= */
add_shortcode( 'pto_calculator', 'pto_calculator_shortcode' );
function pto_calculator_shortcode() {
// Makes the REST URL available to the script below without hardcoding the domain.
$rest_url = esc_url_raw( rest_url( 'pto/v1/calculate' ) );
ob_start();
?>
<div id="pto-calc" class="pto-calc" data-rest-url="<?php echo esc_attr( $rest_url ); ?>">
<div class="pto-card">
<h2 class="pto-title">Paid Time Off Calculator</h2>
<p class="pto-subtitle">Estimate accrued PTO between two dates.</p>
<div class="pto-grid">
<div class="pto-field">
<label for="pto-start">Start Date</label>
<input type="date" id="pto-start">
</div>
<div class="pto-field">
<label for="pto-end">End Date</label>
<input type="date" id="pto-end">
</div>
<div class="pto-field">
<label for="pto-prev">Previous Balance (hrs)</label>
<input type="number" id="pto-prev" min="0" step="0.01" value="0">
</div>
<div class="pto-field">
<label for="pto-max">Max Hours (optional)</label>
<input type="number" id="pto-max" min="0" step="0.01" placeholder="No cap">
</div>
<div class="pto-field">
<label for="pto-accrue">Accrue</label>
<input type="number" id="pto-accrue" min="0" step="0.01" value="1">
</div>
<div class="pto-field">
<label for="pto-every">For Every</label>
<select id="pto-every">
<option value="hour">Hour Worked</option>
<option value="week">Week Worked</option>
<option value="month">Month Worked</option>
</select>
</div>
<div class="pto-field" id="pto-hours-per-day-wrap">
<label for="pto-hpd">Hours Per Day</label>
<input type="number" id="pto-hpd" min="0" step="0.01" value="8">
</div>
<div class="pto-field">
<label for="pto-daysoff">Days Off (in range)</label>
<input type="number" id="pto-daysoff" min="0" step="1" value="0">
</div>
</div>
<div class="pto-field pto-days-worked">
<label>Days Worked</label>
<div class="pto-days">
<label class="pto-day"><input type="checkbox" value="0"><span>Sun</span></label>
<label class="pto-day"><input type="checkbox" value="1" checked><span>Mon</span></label>
<label class="pto-day"><input type="checkbox" value="2" checked><span>Tue</span></label>
<label class="pto-day"><input type="checkbox" value="3" checked><span>Wed</span></label>
<label class="pto-day"><input type="checkbox" value="4" checked><span>Thu</span></label>
<label class="pto-day"><input type="checkbox" value="5" checked><span>Fri</span></label>
<label class="pto-day"><input type="checkbox" value="6"><span>Sat</span></label>
</div>
</div>
<button id="pto-calculate" class="pto-btn">Calculate PTO</button>
<div id="pto-result" class="pto-result" hidden>
<div class="pto-result-row">
<span>Accrued this period</span>
<strong id="pto-out-accrued">0</strong>
</div>
<div class="pto-result-row">
<span>Previous balance</span>
<strong id="pto-out-prev">0</strong>
</div>
<div class="pto-result-row pto-result-total">
<span>Total available</span>
<strong id="pto-out-total">0</strong>
</div>
<p id="pto-out-note" class="pto-note"></p>
</div>
<p id="pto-error" class="pto-error" hidden></p>
</div>
</div>
<?php
return ob_get_clean();
}
/* =========================================================
* 3. Enqueue CSS + JS only on pages that use the shortcode
* ========================================================= */
add_action( 'wp_enqueue_scripts', function () {
global $post;
if ( is_a( $post, 'WP_Post' ) && has_shortcode( $post->post_content, 'pto_calculator' ) ) {
wp_register_style( 'pto-calc-style', false );
wp_enqueue_style( 'pto-calc-style' );
wp_add_inline_style( 'pto-calc-style', pto_calc_css() );
wp_register_script( 'pto-calc-script', '', array(), '1.0.0', true );
wp_enqueue_script( 'pto-calc-script' );
wp_add_inline_script( 'pto-calc-script', pto_calc_js() );
}
} );
function pto_calc_css() {
return <<<CSS
.pto-calc{--pto-accent:#0f766e;--pto-accent-dark:#0b5a54;--pto-bg:#fff;--pto-border:#dbe2e1;--pto-text:#1f2937;--pto-muted:#6b7280;--pto-panel:#f4faf9;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--pto-text);max-width:560px;margin:0 auto}
.pto-card{background:var(--pto-bg);border:1px solid var(--pto-border);border-radius:14px;padding:28px;box-shadow:0 2px 10px rgba(15,118,110,.06)}
.pto-title{margin:0 0 4px;font-size:22px;font-weight:700}
.pto-subtitle{margin:0 0 20px;color:var(--pto-muted);font-size:14px}
.pto-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px;margin-bottom:14px}
.pto-field{display:flex;flex-direction:column;gap:6px}
.pto-field label{font-size:13px;font-weight:600;color:var(--pto-text)}
.pto-field input,.pto-field select{border:1px solid var(--pto-border);border-radius:8px;padding:9px 10px;font-size:14px;color:var(--pto-text);background:#fff;outline:none;transition:border-color .15s ease,box-shadow .15s ease}
.pto-field input:focus,.pto-field select:focus{border-color:var(--pto-accent);box-shadow:0 0 0 3px rgba(15,118,110,.15)}
.pto-days-worked{margin-bottom:18px}
.pto-days{display:flex;flex-wrap:wrap;gap:8px}
.pto-day{display:flex;align-items:center;gap:6px;border:1px solid var(--pto-border);border-radius:8px;padding:6px 10px;font-size:13px;cursor:pointer;user-select:none}
.pto-day input{accent-color:var(--pto-accent);cursor:pointer}
.pto-btn{width:100%;background:var(--pto-accent);color:#fff;border:none;border-radius:10px;padding:12px 16px;font-size:15px;font-weight:700;cursor:pointer;transition:background .15s ease}
.pto-btn:hover{background:var(--pto-accent-dark)}
.pto-btn:disabled{opacity:.6;cursor:not-allowed}
.pto-result{margin-top:20px;background:var(--pto-panel);border:1px solid var(--pto-border);border-radius:10px;padding:16px 18px}
.pto-result-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:14px}
.pto-result-total{border-top:1px solid var(--pto-border);margin-top:6px;padding-top:12px;font-size:16px}
.pto-result-total strong{color:var(--pto-accent-dark);font-size:18px}
.pto-note{margin:10px 0 0;font-size:12px;color:var(--pto-muted)}
.pto-error{margin-top:14px;color:#b91c1c;font-size:13px}
@media (max-width:480px){.pto-grid{grid-template-columns:1fr}}
CSS;
}
function pto_calc_js() {
return <<<JS
(function () {
var root = document.getElementById('pto-calc');
if (!root) return;
var restUrl = root.getAttribute('data-rest-url');
var \$ = function (id) { return document.getElementById(id); };
var everyEl = \$('pto-every');
var hpdWrap = \$('pto-hours-per-day-wrap');
var dayChecks = root.querySelectorAll('.pto-day input');
var btn = \$('pto-calculate');
var errorEl = \$('pto-error');
var resultEl = \$('pto-result');
function toggleHoursPerDay() {
hpdWrap.style.display = (everyEl.value === 'hour') ? '' : 'none';
}
everyEl.addEventListener('change', toggleHoursPerDay);
toggleHoursPerDay();
function showError(msg) {
errorEl.textContent = msg;
errorEl.hidden = false;
resultEl.hidden = true;
}
btn.addEventListener('click', function () {
errorEl.hidden = true;
var startVal = \$('pto-start').value;
var endVal = \$('pto-end').value;
if (!startVal || !endVal) {
showError('Please choose both a start date and an end date.');
return;
}
var workDays = [];
dayChecks.forEach(function (cb) {
if (cb.checked) workDays.push(parseInt(cb.value, 10));
});
var payload = {
start: startVal,
end: endVal,
previousBalance: parseFloat(\$('pto-prev').value) || 0,
accrueRate: parseFloat(\$('pto-accrue').value) || 0,
every: everyEl.value,
hoursPerDay: parseFloat(\$('pto-hpd').value) || 0,
daysOff: parseFloat(\$('pto-daysoff').value) || 0,
maxHours: \$('pto-max').value === '' ? '' : parseFloat(\$('pto-max').value),
workDays: workDays
};
btn.disabled = true;
btn.textContent = 'Calculating...';
fetch(restUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function (res) {
return res.json().then(function (data) {
if (!res.ok) throw new Error(data.message || 'Calculation failed.');
return data;
});
})
.then(function (data) {
\$('pto-out-accrued').textContent = data.accrued + ' hrs';
\$('pto-out-prev').textContent = data.previousBalance + ' hrs';
\$('pto-out-total').textContent = data.total + ' hrs';
\$('pto-out-note').textContent = data.note;
resultEl.hidden = false;
})
.catch(function (err) {
showError(err.message || 'Something went wrong. Please try again.');
})
.finally(function () {
btn.disabled = false;
btn.textContent = 'Calculate PTO';
});
});
})();
JS;
}