Mobile App Guide
This guide covers how to use Shyfts on mobile devices, including touch interactions, responsive features, and mobile-optimised workflows.
Overview
Progressive Web App
Shyfts is a Progressive Web App (PWA) that works on any mobile device:
| Feature | Description |
|---|---|
| No App Store | Access via web browser |
| Home Screen | Add to home screen for app-like experience |
| Offline Support | View cached data offline |
| Push Notifications | Receive alerts (when enabled) |
| Responsive Design | Optimised for all screen sizes |
Supported Devices
| Platform | Browser | Version |
|---|---|---|
| iOS | Safari | 14+ |
| iOS | Chrome | Latest |
| Android | Chrome | 80+ |
| Android | Samsung Internet | Latest |
| Android | Firefox | Latest |
Getting Started
Accessing Shyfts Mobile
- Open your mobile browser
- Navigate to your Shyfts URL
- Login with your credentials
- You're ready to use Shyfts
Add to Home Screen
iPhone/iPad (Safari):
- Open Shyfts in Safari
- Tap the Share button (square with arrow)
- Scroll down and tap Add to Home Screen
- Name it "Shyfts" and tap Add
Android (Chrome):
- Open Shyfts in Chrome
- Tap the Menu (three dots)
- Tap Add to Home Screen
- Tap Add
Home Screen Icon
Once added, Shyfts appears as an app icon:
📱 Your Home Screen
├── 🩺 Shyfts (full-screen experience)
├── 📱 Other Apps
└── ...
Mobile Navigation
Bottom Navigation Bar
On mobile, the main navigation appears at the bottom:
| Icon | Section | Description |
|---|---|---|
| 🏠 | Dashboard | Main overview |
| 📅 | Schedule | Your shifts |
| ⏰ | Time Clock | Clock in/out |
| 📋 | Leave | Leave requests |
| 👤 | Profile | Your settings |
Navigation Component
// Mobile bottom navigation
const MobileNavigation = () => (
<nav className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-md border-t border-white/10 safe-area-bottom">
<div className="flex justify-around py-2">
<NavItem href="/staff" icon={Home} label="Home" />
<NavItem href="/staff/schedule" icon={Calendar} label="Schedule" />
<NavItem href="/staff/time-clock" icon={Clock} label="Clock" />
<NavItem href="/staff/leave" icon={FileText} label="Leave" />
<NavItem href="/staff/profile" icon={User} label="Profile" />
</div>
</nav>
)
// Navigation item with touch target
const NavItem = ({ href, icon: Icon, label }) => (
<Link
href={href}
className="flex flex-col items-center justify-center min-w-[64px] min-h-[44px] p-2"
>
<Icon className="w-6 h-6" />
<span className="text-xs mt-1">{label}</span>
</Link>
)
Safe Area Handling
// Handle iOS safe areas (notch, home indicator)
const safeAreaStyles = `
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
`
Dashboard
Mobile Dashboard Layout
// Mobile dashboard layout
const MobileDashboard = () => (
<div className="min-h-screen pb-20">
{/* Header */}
<header className="sticky top-0 bg-background/95 backdrop-blur-md z-10 p-4 safe-area-top">
<h1 className="text-xl font-semibold">Dashboard</h1>
<p className="text-sm text-secondary-text">
{formatDate(new Date(), 'dddd, D MMMM')}
</p>
</header>
{/* Quick Actions */}
<div className="px-4 py-3">
<QuickActions />
</div>
{/* Today's Shift */}
<section className="px-4 py-3">
<h2 className="text-lg font-medium mb-3">Today</h2>
<TodayShiftCard />
</section>
{/* Upcoming Shifts */}
<section className="px-4 py-3">
<h2 className="text-lg font-medium mb-3">This Week</h2>
<UpcomingShiftsList />
</section>
{/* Notifications */}
<section className="px-4 py-3">
<h2 className="text-lg font-medium mb-3">Notifications</h2>
<NotificationsList />
</section>
</div>
)
Quick Actions
// Mobile quick action buttons
const QuickActions = () => (
<div className="grid grid-cols-2 gap-3">
<ActionButton
icon={Clock}
label="Clock In"
onClick={handleClockIn}
variant="primary"
/>
<ActionButton
icon={Calendar}
label="Request Leave"
onClick={() => navigate('/staff/leave/request')}
variant="secondary"
/>
</div>
)
// Action button with 44px+ touch target
const ActionButton = ({ icon: Icon, label, onClick, variant }) => (
<button
onClick={onClick}
className={`
flex items-center justify-center gap-2 p-4 rounded-xl
min-h-[48px] active:scale-95 transition-transform
${variant === 'primary'
? 'bg-coral-gradient text-white'
: 'bg-white/10 text-white'}
`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{label}</span>
</button>
)
Schedule View
Mobile Calendar
// Mobile schedule calendar
const MobileSchedule = () => {
const [view, setView] = useState<'day' | 'week'>('day')
const [date, setDate] = useState(new Date())
return (
<div className="min-h-screen pb-20">
{/* Header with date picker */}
<header className="sticky top-0 bg-background/95 backdrop-blur-md z-10 p-4">
<div className="flex items-center justify-between">
<button
onClick={() => navigateDate(-1)}
className="p-3 min-w-[44px] min-h-[44px]"
>
<ChevronLeft className="w-6 h-6" />
</button>
<div className="text-center">
<div className="text-lg font-semibold">
{formatDate(date, view === 'day' ? 'ddd, D MMM' : 'Week of D MMM')}
</div>
</div>
<button
onClick={() => navigateDate(1)}
className="p-3 min-w-[44px] min-h-[44px]"
>
<ChevronRight className="w-6 h-6" />
</button>
</div>
{/* View toggle */}
<div className="flex gap-2 mt-3">
<ViewToggle
value={view}
onChange={setView}
options={['day', 'week']}
/>
</div>
</header>
{/* Shift list */}
<div className="p-4 space-y-3">
{shifts.map(shift => (
<MobileShiftCard key={shift.id} shift={shift} />
))}
</div>
</div>
)
}
Mobile Shift Card
// Mobile-optimised shift card
const MobileShiftCard = ({ shift }) => (
<div
className="card-glass p-4 active:scale-[0.98] transition-transform"
onClick={() => viewShiftDetails(shift.id)}
>
<div className="flex justify-between items-start">
<div>
<div className="font-semibold">
{formatTime(shift.start_time)} - {formatTime(shift.end_time)}
</div>
<div className="text-sm text-secondary-text">
{shift.duration}
</div>
</div>
<ShiftStatusBadge status={shift.status} size="sm" />
</div>
{shift.room && (
<div className="mt-3 flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-secondary-text" />
<span>{shift.room.name}</span>
</div>
)}
{shift.notes && (
<div className="mt-2 text-sm text-secondary-text line-clamp-2">
{shift.notes}
</div>
)}
</div>
)
Swipe Gestures
// Swipe navigation for calendar
const useSwipeNavigation = (onSwipeLeft: () => void, onSwipeRight: () => void) => {
const [touchStart, setTouchStart] = useState<number | null>(null)
const [touchEnd, setTouchEnd] = useState<number | null>(null)
const minSwipeDistance = 50
const onTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX)
}
const onTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX)
}
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return
const distance = touchStart - touchEnd
const isLeftSwipe = distance > minSwipeDistance
const isRightSwipe = distance < -minSwipeDistance
if (isLeftSwipe) onSwipeLeft()
if (isRightSwipe) onSwipeRight()
}
return { onTouchStart, onTouchMove, onTouchEnd }
}
Time Clock
Mobile Clock In/Out
// Mobile time clock screen
const MobileTimeClock = () => {
const { currentStatus, clockIn, clockOut, startBreak, endBreak } = useTimeClock()
return (
<div className="min-h-screen pb-20 flex flex-col">
{/* Header */}
<header className="p-4 text-center">
<h1 className="text-xl font-semibold">Time Clock</h1>
<p className="text-secondary-text">
{formatDate(new Date(), 'dddd, D MMMM YYYY')}
</p>
</header>
{/* Current Time Display */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-6xl font-bold tabular-nums">
{formatTime(new Date(), 'HH:mm')}
</div>
<div className="text-xl text-secondary-text mt-2">
{formatTime(new Date(), 'ss')}
</div>
</div>
</div>
{/* Status */}
<div className="px-4 py-6 text-center">
<ClockStatusBadge status={currentStatus} />
{currentStatus === 'clocked_in' && (
<div className="mt-2 text-secondary-text">
Working for {getElapsedTime()}
</div>
)}
</div>
{/* Action Buttons */}
<div className="p-4 space-y-3">
{currentStatus === 'not_clocked_in' && (
<ClockButton
label="Clock In"
icon={LogIn}
onClick={clockIn}
variant="primary"
/>
)}
{currentStatus === 'clocked_in' && (
<>
<ClockButton
label="Start Break"
icon={Coffee}
onClick={startBreak}
variant="secondary"
/>
<ClockButton
label="Clock Out"
icon={LogOut}
onClick={clockOut}
variant="danger"
/>
</>
)}
{currentStatus === 'on_break' && (
<ClockButton
label="End Break"
icon={PlayCircle}
onClick={endBreak}
variant="primary"
/>
)}
</div>
</div>
)
}
Large Touch Buttons
// Large clock action button
const ClockButton = ({ label, icon: Icon, onClick, variant }) => {
const variants = {
primary: 'bg-coral-gradient text-white',
secondary: 'bg-white/10 text-white border border-white/20',
danger: 'bg-red-500/80 text-white'
}
return (
<button
onClick={onClick}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
active:scale-[0.98] transition-transform
min-h-[56px]
${variants[variant]}
`}
>
<Icon className="w-6 h-6" />
{label}
</button>
)
}
Leave Requests
Mobile Leave Form
// Mobile leave request form
const MobileLeaveForm = () => (
<div className="min-h-screen pb-20">
<header className="sticky top-0 bg-background/95 backdrop-blur-md z-10 p-4">
<div className="flex items-center gap-3">
<BackButton />
<h1 className="text-xl font-semibold">Request Leave</h1>
</div>
</header>
<form className="p-4 space-y-4">
{/* Leave Type */}
<div>
<label className="block text-sm font-medium mb-2">Leave Type</label>
<select className="form-select w-full py-3 text-base">
<option value="annual">Annual Leave</option>
<option value="sick">Sick Leave</option>
<option value="emergency">Emergency Leave</option>
</select>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-2">Start Date</label>
<input
type="date"
className="form-input w-full py-3 text-base"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">End Date</label>
<input
type="date"
className="form-input w-full py-3 text-base"
/>
</div>
</div>
{/* Half Day Options */}
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<input type="checkbox" className="w-5 h-5" />
<span>Half day start</span>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<input type="checkbox" className="w-5 h-5" />
<span>Half day end</span>
</label>
</div>
{/* Reason */}
<div>
<label className="block text-sm font-medium mb-2">Reason</label>
<textarea
className="form-input w-full py-3 text-base min-h-[120px]"
placeholder="Please provide a reason for your leave request..."
/>
</div>
{/* Submit */}
<button className="form-button w-full py-4 text-lg mt-6">
Submit Request
</button>
</form>
</div>
)
Leave Balance Card
// Mobile leave balance display
const MobileLeaveBalance = ({ balance }) => (
<div className="card-glass p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold">Leave Balance</h3>
<span className="text-secondary-text text-sm">2025</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-green-400">
{balance.remaining}
</div>
<div className="text-xs text-secondary-text">Remaining</div>
</div>
<div>
<div className="text-2xl font-bold text-amber-400">
{balance.pending}
</div>
<div className="text-xs text-secondary-text">Pending</div>
</div>
<div>
<div className="text-2xl font-bold text-secondary-text">
{balance.used}
</div>
<div className="text-xs text-secondary-text">Used</div>
</div>
</div>
</div>
)
Touch Interactions
Touch Targets
All interactive elements meet the 44px minimum touch target:
| Element | Minimum Size |
|---|---|
| Buttons | 44px × 44px |
| Links | 44px height |
| Form inputs | 48px height |
| Navigation items | 44px × 44px |
| List items | 48px height |
Touch States
// Touch-friendly button with feedback
const TouchButton = ({ children, onClick, ...props }) => (
<button
onClick={onClick}
className={`
min-h-[44px] min-w-[44px] px-4
active:bg-white/5 active:scale-[0.98]
transition-all duration-150
`}
{...props}
>
{children}
</button>
)
Pull to Refresh
// Pull to refresh implementation
const usePullToRefresh = (onRefresh: () => Promise<void>) => {
const [isPulling, setIsPulling] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const threshold = 80 // pixels to pull before refresh triggers
const handleTouchMove = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
const distance = e.touches[0].clientY - startY
if (distance > 0) {
setIsPulling(true)
setPullDistance(Math.min(distance, threshold * 1.5))
}
}
}
const handleTouchEnd = async () => {
if (pullDistance >= threshold) {
setIsRefreshing(true)
await onRefresh()
setIsRefreshing(false)
}
setIsPulling(false)
setPullDistance(0)
}
return {
isPulling,
isRefreshing,
pullDistance,
handlers: { onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd }
}
}
Offline Support
Cached Data
When offline, you can still access:
| Feature | Available Offline |
|---|---|
| View schedule | ✅ Cached shifts |
| View profile | ✅ Cached data |
| Clock in/out | ⚠️ Queued for sync |
| Request leave | ⚠️ Queued for sync |
| View balance | ✅ Last known |
Offline Indicator
// Offline status indicator
const OfflineIndicator = () => {
const isOnline = useOnlineStatus()
if (isOnline) return null
return (
<div className="fixed top-0 left-0 right-0 bg-amber-600 text-white text-center py-2 text-sm z-50 safe-area-top">
You're offline. Some features may be limited.
</div>
)
}
// Hook to track online status
const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
Data Synchronisation
// Sync queued actions when online
const syncQueuedActions = async () => {
const queue = getActionQueue()
for (const action of queue) {
try {
await executeAction(action)
removeFromQueue(action.id)
} catch (error) {
// Keep in queue for retry
console.error('Sync failed:', error)
}
}
}
// Listen for online event to sync
useEffect(() => {
const handleOnline = () => {
syncQueuedActions()
}
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [])
Push Notifications
Enabling Notifications
- Open Shyfts on your mobile device
- Go to Profile → Preferences
- Toggle Push Notifications on
- Allow when prompted by browser
Notification Types
| Notification | Description |
|---|---|
| Shift Reminder | Before your shift starts |
| Schedule Change | When your shift is modified |
| Leave Update | Request approved/rejected |
| Swap Request | New swap request received |
Request Permission
// Request push notification permission
const requestNotificationPermission = async () => {
if (!('Notification' in window)) {
return { supported: false }
}
if (Notification.permission === 'granted') {
return { supported: true, granted: true }
}
if (Notification.permission === 'denied') {
return { supported: true, granted: false, denied: true }
}
const permission = await Notification.requestPermission()
return { supported: true, granted: permission === 'granted' }
}
Form Input
Mobile-Optimised Forms
// Mobile form input with proper keyboard support
const MobileInput = ({ label, type, value, onChange, placeholder }) => (
<div>
<label className="block text-sm font-medium mb-2">{label}</label>
<input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
className={`
form-input w-full py-3 text-base
focus:ring-2 focus:ring-coral-500
`}
// Prevent zoom on iOS
style={{ fontSize: '16px' }}
/>
</div>
)
Input Types for Mobile
| Field Type | Input Type | Keyboard |
|---|---|---|
type="email" | Email keyboard | |
| Phone | type="tel" | Phone keyboard |
| Number | type="number" | Numeric keyboard |
| Date | type="date" | Date picker |
| Time | type="time" | Time picker |
| Search | type="search" | Search keyboard |
Date Picker
// Mobile-friendly date picker
const MobileDatePicker = ({ label, value, onChange, min, max }) => (
<div>
<label className="block text-sm font-medium mb-2">{label}</label>
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
min={min}
max={max}
className="form-input w-full py-3 text-base"
// Show native date picker on tap
/>
</div>
)
Performance
Mobile Optimisations
| Optimisation | Benefit |
|---|---|
| Lazy loading | Faster initial load |
| Image compression | Reduced data usage |
| Code splitting | Smaller bundles |
| Service worker | Offline caching |
Loading States
// Mobile loading skeleton
const MobileShiftSkeleton = () => (
<div className="card-glass p-4 animate-pulse">
<div className="h-5 bg-white/10 rounded w-1/2 mb-2" />
<div className="h-4 bg-white/10 rounded w-1/3" />
<div className="h-4 bg-white/10 rounded w-2/3 mt-3" />
</div>
)
// Loading state wrapper
const LoadingWrapper = ({ isLoading, children, skeleton }) => {
if (isLoading) {
return skeleton || <DefaultSkeleton />
}
return children
}
Accessibility
Mobile Accessibility
| Feature | Implementation |
|---|---|
| Touch targets | 44px minimum |
| Colour contrast | 4.5:1 ratio |
| Font size | 16px minimum |
| Focus indicators | Visible on tap |
| Screen reader | ARIA labels |
VoiceOver/TalkBack Support
// Accessible button with ARIA
const AccessibleButton = ({ label, onClick, icon: Icon }) => (
<button
onClick={onClick}
aria-label={label}
className="min-h-[44px] min-w-[44px] p-3"
>
<Icon className="w-6 h-6" aria-hidden="true" />
<span className="sr-only">{label}</span>
</button>
)
// Accessible status announcement
const StatusAnnouncement = ({ message }) => (
<div
role="status"
aria-live="polite"
className="sr-only"
>
{message}
</div>
)
Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Won't add to home screen | Browser limitation | Try different browser |
| Notifications not working | Permission denied | Check browser settings |
| Slow loading | Poor connection | Use WiFi or wait |
| Form zoom on iOS | Font size < 16px | Already fixed in Shyfts |
| Gestures not working | Touch event issues | Clear cache, restart browser |
Clearing Cache
iPhone/iPad:
- Settings → Safari → Clear History and Website Data
- Or: Settings → Safari → Advanced → Website Data → Remove for Shyfts
Android:
- Chrome → Menu → Settings → Privacy → Clear Browsing Data
- Select "Cached images and files"
- Tap "Clear Data"
Best Practices
Mobile Usage Tips
- Add to home screen - For best experience
- Enable notifications - Stay informed
- Use WiFi - For initial setup
- Keep logged in - Persistent session
- Update browser - Latest features
Battery Considerations
| Feature | Battery Impact |
|---|---|
| Location services | Medium |
| Push notifications | Low |
| Background sync | Low |
| Screen brightness | High |
Related Documentation
- Dashboard - Staff dashboard
- Viewing Shifts - Schedule guide
- Clock In/Out - Time tracking
- Preferences - Notification settings
Source Files:
src/components/mobile/MobileNavigation.tsx- Bottom navigationsrc/components/mobile/MobileShiftCard.tsx- Shift cardssrc/hooks/useSwipeGesture.ts- Swipe detectionsrc/hooks/useOnlineStatus.ts- Network statussrc/lib/pwa/service-worker.ts- Offline support