Leave Balance
This guide covers how to check your leave balance, understand entitlements, and view how leave is calculated.
Overview
What is Leave Balance?
Your leave balance shows your remaining time off entitlement:
- Annual Entitlement - Total days you're entitled to per year
- Used Leave - Days already taken or approved
- Remaining Balance - Days still available
- Pending Requests - Days awaiting approval
Accessing Leave Balance
- Navigate to Staff Portal → Leave from the sidebar
- Your current balance is shown at the top of the page
- Click on the balance card for detailed breakdown
Leave Balance API
Fetch Your Balance
Endpoint: GET /api/staff/leave-balance
// Source: src/app/api/staff/leave-balance/route.ts:25-65
const leaveBalanceQuerySchema = z.object({
year: z.coerce.number().min(2020).max(2099).optional(),
include_breakdown: z.enum(['true', 'false']).optional().default('true'),
include_history: z.enum(['true', 'false']).optional().default('false')
})
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| year | number | No | Leave year (default: current year) |
| include_breakdown | boolean | No | Include detailed breakdown (default: true) |
| include_history | boolean | No | Include usage history (default: false) |
Example Request
// Fetch current year's balance
const getLeaveBalance = async () => {
const response = await fetch('/api/staff/leave-balance')
return response.json()
}
// Fetch specific year with history
const getBalanceWithHistory = async (year: number) => {
const response = await fetch(
`/api/staff/leave-balance?year=${year}&include_history=true`
)
return response.json()
}
Response Format
// Leave balance response
{
success: true,
data: {
year: 2025,
staff_id: "staff-uuid",
// Summary
entitlement: 28.0,
used: 10.0,
pending: 5.0,
remaining: 13.0,
// Breakdown by type
breakdown: {
annual: {
entitlement: 28.0,
used: 10.0,
pending: 5.0,
remaining: 13.0
},
toil: {
accrued: 3.5,
used: 1.0,
remaining: 2.5
}
},
// Policy details
policy: {
carry_over_allowed: true,
carry_over_max: 5.0,
carry_over_expiry: "2025-03-31",
pro_rata_applied: false
},
// Dates
leave_year_start: "2025-01-01",
leave_year_end: "2025-12-31",
calculated_at: "2025-01-14T10:00:00Z"
}
}
Balance Display
Balance Overview Component
// Leave balance display
const LeaveBalanceOverview = () => {
const { data: balance, isLoading } = useLeaveBalance()
if (isLoading) return <BalanceSkeleton />
return (
<div className="card-glass p-6">
{/* Main balance */}
<div className="text-center">
<div className="text-4xl font-bold text-coral-gradient">
{balance.remaining}
</div>
<div className="text-secondary-text mt-1">days remaining</div>
</div>
{/* Progress bar */}
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span>Used: {balance.used} days</span>
<span>Total: {balance.entitlement} days</span>
</div>
<div className="h-3 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-coral-gradient rounded-full transition-all"
style={{ width: `${(balance.used / balance.entitlement) * 100}%` }}
/>
</div>
</div>
{/* Pending indicator */}
{balance.pending > 0 && (
<div className="mt-4 p-3 bg-yellow-500/10 rounded-lg text-sm">
<span className="text-yellow-400 font-medium">
{balance.pending} days pending approval
</span>
</div>
)}
</div>
)
}
Balance Breakdown
Detailed Breakdown
// Balance breakdown display
const LeaveBalanceBreakdown = ({ breakdown }) => (
<div className="card-glass p-6 space-y-6">
<h3 className="font-semibold">Leave Breakdown</h3>
{/* Annual Leave */}
<div className="space-y-2">
<div className="flex justify-between">
<span>Annual Leave</span>
<span className="font-medium">{breakdown.annual.remaining} days</span>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-secondary-text">Entitlement</span>
<div>{breakdown.annual.entitlement} days</div>
</div>
<div>
<span className="text-secondary-text">Used</span>
<div>{breakdown.annual.used} days</div>
</div>
<div>
<span className="text-secondary-text">Pending</span>
<div>{breakdown.annual.pending} days</div>
</div>
</div>
</div>
{/* TOIL */}
{breakdown.toil && (
<div className="space-y-2 pt-4 border-t border-white/10">
<div className="flex justify-between">
<span>Time Off In Lieu (TOIL)</span>
<span className="font-medium">{breakdown.toil.remaining} days</span>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-secondary-text">Accrued</span>
<div>{breakdown.toil.accrued} days</div>
</div>
<div>
<span className="text-secondary-text">Used</span>
<div>{breakdown.toil.used} days</div>
</div>
<div>
<span className="text-secondary-text">Remaining</span>
<div>{breakdown.toil.remaining} days</div>
</div>
</div>
</div>
)}
</div>
)
Entitlement Calculation
Annual Entitlement
Your annual leave entitlement is based on:
| Factor | Description |
|---|---|
| Base Entitlement | Standard company allowance |
| Service Years | Additional days for long service |
| Contract Type | Full-time vs part-time ratio |
| Pro-Rata | Adjusted for start date if mid-year |
Calculation Formula
// Source: src/lib/utils/leave.ts:25-80
// Calculate annual leave entitlement
const calculateEntitlement = (staff: Staff): number => {
const { company, contract_type, employment_start_date, fte_ratio } = staff
// Base entitlement from company policy
let entitlement = company.leave_policy.base_annual_days
// Add service-based additional days
const yearsOfService = calculateYearsOfService(employment_start_date)
const additionalDays = getServiceBonus(yearsOfService, company.leave_policy)
entitlement += additionalDays
// Apply FTE ratio for part-time staff
if (contract_type !== 'full_time' && fte_ratio) {
entitlement = entitlement * fte_ratio
}
// Apply pro-rata for mid-year starters
if (isCurrentYearStarter(employment_start_date)) {
const monthsRemaining = getMonthsRemainingInYear(employment_start_date)
entitlement = (entitlement / 12) * monthsRemaining
}
return Math.round(entitlement * 2) / 2 // Round to nearest 0.5
}
Service Bonus Example
| Years of Service | Additional Days |
|---|---|
| 0-2 years | 0 days |
| 3-5 years | 1 day |
| 6-10 years | 2 days |
| 10+ years | 3 days |
Part-Time Calculation
FTE (Full-Time Equivalent)
Part-time staff receive pro-rata entitlement:
// FTE calculation examples
const fteExamples = [
{ hours: 37.5, fte: 1.0, entitlement: 28 }, // Full-time
{ hours: 30, fte: 0.8, entitlement: 22.4 }, // 4 days/week
{ hours: 22.5, fte: 0.6, entitlement: 16.8 }, // 3 days/week
{ hours: 15, fte: 0.4, entitlement: 11.2 }, // 2 days/week
]
Part-Time Display
// Part-time balance info
const PartTimeBalanceInfo = ({ staff, balance }) => {
if (staff.fte_ratio === 1.0) return null
return (
<div className="p-4 bg-blue-500/10 rounded-lg text-sm">
<div className="font-medium text-blue-400">Part-Time Calculation</div>
<p className="mt-1 text-secondary-text">
Your entitlement is pro-rated based on your contracted hours
({staff.contracted_hours} hours/week = {staff.fte_ratio * 100}% FTE)
</p>
<div className="mt-2 grid grid-cols-2 gap-4">
<div>
<span className="text-secondary-text">Full-time equivalent</span>
<div>{balance.full_time_equivalent} days</div>
</div>
<div>
<span className="text-secondary-text">Your entitlement</span>
<div>{balance.entitlement} days</div>
</div>
</div>
</div>
)
}
Carry Over
Carry Over Policy
Unused leave may carry forward:
| Policy | Description |
|---|---|
| Allowed | Can carry over unused days |
| Maximum | Max days that can carry over |
| Expiry | Date by which carried leave must be used |
Carry Over Display
// Carry over information
const CarryOverInfo = ({ policy, balance }) => {
if (!policy.carry_over_allowed) {
return (
<div className="p-4 bg-orange-500/10 rounded-lg">
<span className="text-orange-400 font-medium">No Carry Over</span>
<p className="text-sm text-secondary-text mt-1">
Unused leave cannot be carried to next year. Use it or lose it!
</p>
</div>
)
}
return (
<div className="p-4 bg-white/5 rounded-lg space-y-3">
<div className="font-medium">Carry Over Policy</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-secondary-text">Maximum carry over</span>
<div>{policy.carry_over_max} days</div>
</div>
<div>
<span className="text-secondary-text">Must be used by</span>
<div>{moment(policy.carry_over_expiry).format('D MMMM YYYY')}</div>
</div>
</div>
{balance.carried_over > 0 && (
<div className="pt-3 border-t border-white/10">
<div className="flex justify-between">
<span>Carried from last year</span>
<span className="font-medium">{balance.carried_over} days</span>
</div>
{moment().isAfter(policy.carry_over_expiry) && (
<p className="text-sm text-red-400 mt-1">
Carry over has expired - these days are no longer available
</p>
)}
</div>
)}
</div>
)
}
Leave History
Usage History
// Fetch balance with history
const { data } = await fetch('/api/staff/leave-balance?include_history=true')
// History response
{
// ... balance data ...
history: [
{
id: "request-uuid-1",
leave_type: "annual",
start_date: "2025-01-06",
end_date: "2025-01-10",
total_days: 5.0,
status: "approved",
reason: "Holiday"
},
{
id: "request-uuid-2",
leave_type: "annual",
start_date: "2024-12-23",
end_date: "2024-12-27",
total_days: 3.0,
status: "approved",
reason: "Christmas break"
}
]
}
History Display
// Leave history list
const LeaveHistoryList = ({ history }) => (
<div className="card-glass p-6">
<h3 className="font-semibold mb-4">Leave History</h3>
<div className="space-y-3">
{history.map(entry => (
<div key={entry.id} className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<div>
<div className="font-medium">
{moment(entry.start_date).format('D MMM')} -
{moment(entry.end_date).format('D MMM YYYY')}
</div>
<div className="text-sm text-secondary-text">{entry.reason}</div>
</div>
<div className="text-right">
<div className="font-medium">
{entry.total_days} {entry.total_days === 1 ? 'day' : 'days'}
</div>
<LeaveStatusBadge status={entry.status} size="sm" />
</div>
</div>
))}
</div>
</div>
)
TOIL (Time Off In Lieu)
What is TOIL?
Time Off In Lieu is compensatory time off for extra hours worked:
| Aspect | Description |
|---|---|
| Accrual | Earned by working overtime |
| Usage | Used like annual leave |
| Expiry | May expire after set period |
| Approval | Manager approves accrual and usage |
TOIL Balance
// TOIL balance display
const TOILBalance = ({ toil }) => {
if (!toil || toil.accrued === 0) {
return (
<div className="p-4 bg-white/5 rounded-lg text-center text-secondary-text">
No TOIL accrued
</div>
)
}
return (
<div className="card-glass p-4 space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-medium">Time Off In Lieu</h4>
<span className="text-2xl font-bold">{toil.remaining} days</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-secondary-text">Total Accrued</span>
<div>{toil.accrued} days</div>
</div>
<div>
<span className="text-secondary-text">Used</span>
<div>{toil.used} days</div>
</div>
</div>
{toil.expiry_date && (
<div className="pt-3 border-t border-white/10 text-sm">
<span className="text-secondary-text">Use by: </span>
<span>{moment(toil.expiry_date).format('D MMMM YYYY')}</span>
</div>
)}
</div>
)
}
Year Comparison
Compare Years
// Year comparison component
const YearComparison = () => {
const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(currentYear)
const { data: balance } = useLeaveBalance({ year: selectedYear })
return (
<div className="space-y-4">
{/* Year selector */}
<div className="flex gap-2">
{[currentYear - 1, currentYear, currentYear + 1].map(year => (
<button
key={year}
onClick={() => setSelectedYear(year)}
className={`px-4 py-2 rounded-lg ${
selectedYear === year
? 'bg-coral-gradient text-white'
: 'bg-white/5 text-secondary-text'
}`}
>
{year}
</button>
))}
</div>
{/* Balance for selected year */}
<LeaveBalanceBreakdown breakdown={balance?.breakdown} />
</div>
)
}
Calendar View
Balance Calendar
See leave distribution across the year:
// Leave calendar display
const LeaveCalendar = ({ history }) => {
const months = useMemo(() => {
// Group leave by month
const grouped = {}
history.forEach(entry => {
const month = moment(entry.start_date).format('YYYY-MM')
if (!grouped[month]) grouped[month] = []
grouped[month].push(entry)
})
return grouped
}, [history])
return (
<div className="card-glass p-6">
<h3 className="font-semibold mb-4">Leave Calendar</h3>
<div className="grid grid-cols-4 gap-2">
{['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map((month, index) => {
const key = moment().year() + '-' + String(index + 1).padStart(2, '0')
const leaves = months[key] || []
const totalDays = leaves.reduce((sum, l) => sum + l.total_days, 0)
return (
<div
key={month}
className={`p-2 rounded text-center text-sm ${
totalDays > 0 ? 'bg-coral-500/20' : 'bg-white/5'
}`}
>
<div className="font-medium">{month}</div>
{totalDays > 0 && (
<div className="text-xs text-secondary-text">{totalDays}d</div>
)}
</div>
)
})}
</div>
</div>
)
}
Alerts and Reminders
Balance Alerts
// Balance alert messages
const BalanceAlerts = ({ balance, policy }) => {
const alerts = []
// Low balance warning
if (balance.remaining <= 5 && balance.remaining > 0) {
alerts.push({
type: 'warning',
message: `Only ${balance.remaining} days remaining this year`
})
}
// No balance left
if (balance.remaining <= 0) {
alerts.push({
type: 'error',
message: 'No annual leave remaining for this year'
})
}
// Carry over expiring soon
if (balance.carried_over > 0 && policy.carry_over_expiry) {
const daysUntilExpiry = moment(policy.carry_over_expiry).diff(moment(), 'days')
if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
alerts.push({
type: 'warning',
message: `${balance.carried_over} carried-over days expire in ${daysUntilExpiry} days`
})
}
}
// Lots of unused leave near year end
const monthsLeft = 12 - moment().month()
if (monthsLeft <= 3 && balance.remaining > 10) {
alerts.push({
type: 'info',
message: `${balance.remaining} days remaining - consider booking time off`
})
}
if (alerts.length === 0) return null
return (
<div className="space-y-2">
{alerts.map((alert, index) => (
<div
key={index}
className={`p-3 rounded-lg ${
alert.type === 'error' ? 'bg-red-500/10 text-red-400' :
alert.type === 'warning' ? 'bg-yellow-500/10 text-yellow-400' :
'bg-blue-500/10 text-blue-400'
}`}
>
{alert.message}
</div>
))}
</div>
)
}
Mobile View
Mobile Balance Display
// Mobile balance view
const MobileLeaveBalance = () => {
const { data: balance, isLoading } = useLeaveBalance()
if (isLoading) return <MobileBalanceSkeleton />
return (
<div className="p-4 space-y-4">
{/* Main balance card */}
<div className="card-glass p-6 text-center">
<div className="text-5xl font-bold text-coral-gradient">
{balance.remaining}
</div>
<div className="text-secondary-text mt-1">days remaining</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4 mt-6 text-sm">
<div>
<div className="font-medium">{balance.entitlement}</div>
<div className="text-secondary-text">Total</div>
</div>
<div>
<div className="font-medium">{balance.used}</div>
<div className="text-secondary-text">Used</div>
</div>
<div>
<div className="font-medium">{balance.pending}</div>
<div className="text-secondary-text">Pending</div>
</div>
</div>
</div>
{/* Alerts */}
<BalanceAlerts balance={balance} policy={balance.policy} />
{/* Quick action */}
<button
onClick={() => router.push('/staff/leave/request')}
className="form-button w-full h-12"
>
Request Leave
</button>
</div>
)
}
Understanding Your Balance
Key Terms
| Term | Meaning |
|---|---|
| Entitlement | Total days you can take this year |
| Used | Days already taken (approved) |
| Pending | Days in requests awaiting approval |
| Remaining | Entitlement minus used minus pending |
| Carried Over | Days brought from previous year |
| TOIL | Extra days earned from overtime |
Balance Formula
Remaining = Entitlement + Carried Over + TOIL - Used - Pending
How Bank Holidays Affect Your Balance
Holiday-Aware Calculations
When you request leave, Shyfts automatically excludes bank holidays and company closures from the day count — just like weekends. This means you use fewer leave days when holidays fall within your requested dates.
Example
Requesting leave from Monday 21 December to Friday 25 December (Christmas week):
| Date | Day | Type | Counted? |
|---|---|---|---|
| 21 Dec | Monday | Working day | Yes (1 day) |
| 22 Dec | Tuesday | Working day | Yes (1 day) |
| 23 Dec | Wednesday | Working day | Yes (1 day) |
| 24 Dec | Thursday | Company Closure | No |
| 25 Dec | Friday | Christmas Day (Bank Holiday) | No |
Result: Only 3 days deducted from your balance instead of 5.
What Counts as a Holiday?
Your company's holiday calendar includes:
| Type | Description |
|---|---|
| Bank Holidays | Official UK public holidays (e.g., Christmas Day, Easter Monday) |
| Company Closures | Days your company is closed (e.g., annual shutdown) |
| Custom Holidays | Other company-specific dates (e.g., training days) |
Viewing Your Company's Holidays
Holidays are visible on the scheduling calendar:
- Day view: An amber badge shows the holiday name
- Week view: Holiday names appear under each date with an amber tint
Impact on Your Balance
| Aspect | How Holidays Apply |
|---|---|
| Leave requests | Holidays within your dates are automatically excluded |
| Balance display | "Days requested" reflects the holiday-adjusted count |
| Pending requests | Holiday-aware calculation applied at submission time |
When planning leave around Christmas or Easter, check the calendar for bank holidays first. You might need fewer leave days than you think!
Best Practices
Managing Your Balance
- Plan Ahead - Book leave early in the year
- Check Regularly - Monitor your balance
- Use Carry Over - Don't let carried days expire
- Spread Leave - Take breaks throughout the year
- Consider Team - Coordinate with colleagues
Balance Management Tips
| Do | Don't |
|---|---|
| Plan annual leave early | Leave it all to December |
| Use carry-over first | Let carry-over expire |
| Take regular breaks | Save all leave |
| Check balance before requesting | Assume you have days left |
Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Balance seems wrong | Calculation pending | Refresh page |
| Missing entitlement | Contract not updated | Contact HR |
| No TOIL showing | Not accrued yet | Check with manager |
| Carry-over missing | Expired or not applied | Check policy dates |
Error Messages
| Error | Meaning | Action |
|---|---|---|
| "Balance not found" | No entitlement set | Contact HR |
| "Year not available" | Future year requested | Select valid year |
| "Calculation error" | System issue | Retry or contact support |
Related Documentation
- Requesting Leave - Submit requests
- Viewing Requests - Track requests
- Dashboard - Staff dashboard
- Profile - Your employment details
- Holidays (Manager Guide) - How holidays are managed
Source Files:
src/app/api/staff/leave-balance/route.ts- Leave balance APIsrc/components/staff/LeaveBalanceCard.tsx- Balance card componentsrc/lib/utils/leave.ts- Leave calculation utilitiessrc/types/leave.types.ts- Type definitions