# BuyerKiosk Scheduling vs. WhenIWork — Competitive Analysis & Replacement Roadmap

**Author**: Strategic analysis
**Date**: 2026-05-08
**Scope**: BuyerKiosk web (`/Users/rvanvuren/Projects/buyerkiosk-web`), Team Flutter app (`buyerkiosk-team`), Live Flutter app (`buyerkiosk-live-flutter`) vs. https://wheniwork.com

---

## Executive Summary

BuyerKiosk is closer to WhenIWork parity than the org chart suggests, with two genuine differentiators they cannot match (AI-powered scheduling, iOS Live Activity) and one structural moat (POS-native platform). There are ~7 real capability gaps that would let a customer pick WhenIWork over BuyerKiosk today, and most are mobile-UI completions of backend that has already shipped.

**Verdict**: replacement-grade in 6 sprints (~12 calendar weeks at normal cadence, ~6 weeks if dedicated), not a rebuild.

**Strategic outcome**: After Sprint 3, the sales pitch flips from *"we integrate with WhenIWork"* to *"we replaced WhenIWork."* Customers stop paying two vendors and reconciling two sources of truth.

---

## 1. Capability Matrix

Verified status across web + both Flutter apps vs. WhenIWork's published feature set.

| Category | BuyerKiosk | WhenIWork | Status |
|---|---|---|---|
| Drag-drop schedule builder (web) | Built — Syncfusion Schedule | Built | Parity |
| Drag-drop schedule builder (mobile) | Form-based only | Built | **Gap** |
| Recurring shifts (RRULE) | Built — iCal RRULE | Built | Parity |
| Schedule templates | Built — full CRUD + load preview | Built | Parity |
| Copy previous week | Built — Live app + web | Built | Parity |
| AutoFill / 1-click auto-schedule | Built — AI + constraint solver | Built — AutoFill | **Differentiator** |
| Open shifts (claim) | Built — eligibility checks | Built | Parity |
| Open shifts (bidding) | Not built | Built | **Gap** |
| Shift swaps | Built — two-stage with OT warning | Built — single approval | **Differentiator** |
| Shift drop (to specific coworker) | Not built | Built | **Gap** |
| Time-off requests | Built | Built | Parity |
| PTO accrual ledger | Not built | Built | **Gap** |
| Availability mgmt — backend | Built — recurring + exceptions | Built | Parity |
| Availability mgmt — mobile UI | Not built | Built | **Gap** |
| Geofence clock-in | Built — 250m, Haversine, accuracy gate | Built | Parity |
| Schedule-window enforcement on clock | Built — config-driven hard block | Built | Parity (verified) |
| Break tracking (schema) | Built — paid/unpaid distinction | Built | Parity |
| Break punch UI (mobile) | Not built | Built | **Gap** |
| Manager-on-behalf clock | Built — punch override | Built | Parity |
| iOS Live Activity (Dynamic Island) | Built | Not built | **Differentiator** |
| Offline punch queue (idempotent) | Built | Limited | **Differentiator** |
| Labor cost live + budget variance | Built | Built | Parity |
| Demand/sales heatmap overlays | Built — POS-correlated | Not built (separate forecasting product) | **Differentiator** |
| Pay-period forecast card | Not built | Built | **Gap** |
| Attendance dashboard (late/no-show) | Built — Who's Working | Built | Parity |
| Batch approve/deny | Built | Built | Parity |
| Audit trail (full before/after) | Built — `scheduleAuditLog` | Built | Parity |
| Shift task lists — backend | Built — `ShiftTaskController` (Spec 039) | Built | Parity |
| Shift task lists — mobile UI | Not built | Built | **Gap** |
| Granular notifications (channel × cat + DND) | Built | Simpler | **Differentiator** |
| Push (FCM) + SMS | Built | Built | Parity |
| Team chat — channels | Built — Ably | Built | Parity |
| Team chat — 1:1 DMs | Not built | Built | **Gap** |
| Team chat — broadcasts/announcements | Not built | Built | **Gap** |
| Read receipts (real, persisted) | Partial — `lastReadMessageId` UI flag only | Built | **Gap** |
| Public API / Zapier | Not built | Built | **Gap** |
| Payroll integrations (Gusto/ADP/Rippling) | Not built | Built | **Gap** |
| POS integration depth | Owns POS (DRS) | Square only via Square Payroll | **Moat** |
| AI-driven scheduling | Built — OpenAI + constraint solver | Rule-based AutoFill | **Differentiator** |
| Homebase bidirectional sync | Stub only | N/A | (Internal gap, not WIW comparison) |

---

## 2. Differentiators (already winning)

1. **AI + constraint solver scheduling** — WhenIWork's AutoFill is rule-based; BuyerKiosk has OpenAI suggestions plus a deterministic solver with scorecard metrics.
2. **iOS Live Activity** during clock-in — exclusive to BuyerKiosk Team app.
3. **Sales/wait-time heatmap overlays on the schedule** — demand-to-staffing correlation enabled by POS ownership. WhenIWork sells this as a separate product.
4. **Two-stage shift swap with overtime preview** — protects against accidental OT; WhenIWork's swap is single approval.
5. **Granular notification matrix** (category × channel + quiet hours) — meaningfully more flexible than WhenIWork.
6. **POS-native platform** — schedule + POS + buy-floor + cash + loyalty + comeback cash sold as one system. WhenIWork is forever a bolt-on. The structural moat.
7. **Offline punch queue with idempotency** — robust beyond WhenIWork's offline behavior.

---

## 3. Verified Capability Notes

Items that came back ambiguous in initial pass; verified via code inspection.

### Shift task lists in mobile UI — Partially built
- **Backend**: complete CRUD at `userfrosting/src/BuyerKiosk/Scheduling/Controllers/ShiftTaskController.php:71-364`, including Ably real-time sync (lines 287-340).
- **Mobile**: no employee task display/check-off UI; no manager assignment UI in the scheduling flow.
- **Implication**: classic backend-paid-for, mobile-unwired gap. High leverage.

### Schedule-window enforcement on clock-in — Built
- `MobileClockService.php:203-212` throws `OUTSIDE_WINDOW` `RuntimeException` before creating punch.
- `checkClockInEligibility()` (lines 721-792) respects `requireManagerOverrideOutsideClockWindow` config flag.
- `MobileClockController.php:173-174` returns 403 when blocked.
- **Implication**: full parity, including configurability beyond WhenIWork's binary behavior.

### Read receipts — Partially built
- `lastReadMessageId` is tracked in Live app's `channel_screen.dart:20-30` and `message_group.dart` for the "new messages divider" UI only.
- No `readReceipt`/`seenBy`/`readAt` entities, no backend endpoint, no Ably channel pattern for marking messages read.
- **Implication**: requires real per-user read state + Ably broadcast. Reclassify as a real gap.

### Homebase bidirectional sync — Stub only
- `userforsting/src/BuyerKiosk/Workbook/HomebaseSchedule.php:1-88` extends `ScheduleProvider` correctly but every method returns empty/`false`/TODO.
- `isEnabled()` hardcoded `false` (line 22).
- Existing test `HomebaseProviderIntegrationTest.php` only validates the stub.
- Contrast: `WhenIWorkSchedule.php` is a full 342-line implementation.
- **Implication**: ~300 LOC of work to match WhenIWork-integration depth. Internal gap, not a WhenIWork-comparison gap.

---

## 4. Phased Roadmap — 6 Sprints to Replacement

### Sprint 1 (Weeks 1-2) — Quick Wins, Independent Workstreams

Three gaps, zero cross-dependencies, max parallelization.

| Gap | Owner | Effort | Notes |
|---|---|---|---|
| **Availability UI (Team app)** | Flutter | M (~3 days) | Backend 100% complete (Spec 022). New entity, repo, provider, screen. Add settings list tile + route. |
| **Break punch buttons (Team app)** | Backend (1d) + Flutter (1d) | S (~1 day) | Add `POST /:typeNum/clock/break/start` + `/end` routes; extend `clock_status.dart` with `onBreak`; conditional UI on clock screen. Verify `scheduleTimePunches.punchType` enum already includes break types (1d ALTER if not). |
| **Pay-period forecast card (Live app)** | Backend (2d) + Flutter (2d) | M (~2-4 days) | Add `LaborCostCalculator::calculatePeriodForecast()` + `getLaborForecast()` mobile endpoint + `LaborForecastCard` widget on `store_metrics_screen.dart`. Respect `payPeriodType` config (weekly/biweekly/semi-monthly). |

**Deliverable**: 3 customer-visible features in 2 weeks.

### Sprint 2 (Weeks 3-4) — Mobile Manager Power + Bidding

| Gap | Owner | Effort | Notes |
|---|---|---|---|
| **Drag-drop schedule builder (Live app)** | Flutter | L (~5-7 days) | `SfCalendar` (`syncfusion_flutter_calendar`) `WeekView` with `allowDragAndDrop: true`. Existing update endpoint at `mobile-scheduling.php:710`. Optimistic update + conflict rollback. Wire `onTap` to existing `ShiftFormScreen` for full edits. |
| **OpenShift Bidding flow** | Backend (4d) + Flutter cross-app (3d) | L (~7 days total) | New `shiftBids` table + `claimMode ENUM('first_claim','bid')` on `scheduleShifts`. New `OpenShiftBiddingService` with `placeBid`/`withdrawBid`/`selectWinner`. Race-safe: transaction with `FOR UPDATE` on shift row. Team app: "Express Interest" button conditional on `claimMode`. Live app: bids list + winner selection. |

### Sprint 3 (Weeks 5-6) — Comms & Compliance

| Gap | Owner | Effort | Notes |
|---|---|---|---|
| **1:1 DMs in chat** | Backend (5d) + Flutter (5d) | L | New `dm_threads` + `dm_messages` schema. Unique constraint on `(typeNum, LEAST(user1, user2), GREATEST(user1, user2))` prevents duplicate pairs. Ably channel `store:{typeNum}:dm:{threadId}`. New `DmService`, four endpoints, Flutter thread/list screens. |
| **PTO accrual ledger v1** | Backend | M-L (~5 days) | `ptoAccrualRules` + `ptoLedger` tables (central DB). Running-sum balance (no denormalized column). `PtoAccrualService::accrueForPeriod()` as TaskEngine job. Idempotent via unique key on `(userId, typeNum, periodEnd, transactionType)`. Wire `deductForApprovedTimeOff()` into `ApprovalQueueController`. **Defer Gusto CSV export to Sprint 4.** |
| **Shift task lists in mobile UI** | Flutter | M (~3-4 days) | Pure UI. Backend + Ably real-time sync already shipped. Team app: tasks section on shift detail with check-off. Live app: assignment UI in shift form. |

### Sprint 4 (Weeks 7-8, optional) — Platform Plays

- **Real read receipts** — replace `lastReadMessageId` UI flag with persisted per-user read state + Ably broadcast.
- **Gusto CSV exporter** — `TimesheetExporter::exportGustoCsv(typeNum, periodStart, periodEnd)`. Format: Employee ID, First/Last Name, Pay Rate, Regular Hours, Overtime Hours, PTO Hours. Maps `userStoreAssignments.drsEmployeeId` to Gusto-side ID. Validate against current Gusto import spec before shipping.
- **Public API + Zapier app** — required if pursuing self-serve SaaS pricing. Auth via API keys, rate-limited.
- **All-staff broadcast / announcements** — new "send to everyone working tomorrow" flow on Live app.
- **Shift drop-to-coworker** — variant of swap, lighter than bidding.
- **Homebase integration completion** — only if pursuing Homebase as a customer migration path.

---

## 5. Effort Summary

| Gap | Size | Backend Days | Flutter Days |
|---|---|---|---|
| Drag-drop calendar (Live) | L | 1 | 7 |
| Availability UI (Team) | M | 0 | 3 |
| Break punches (Team) | S | 1 | 1 |
| 1:1 DMs (Team) | L | 5 | 5 |
| OpenShift Bidding (cross-app) | L | 4 | 3 |
| Pay-period forecast card (Live) | M | 2 | 2 |
| Shift tasks mobile UI (cross-app) | M | 0 | 4 |
| PTO accrual ledger | M-L | 5 | 0 |
| Gusto CSV export (Sprint 4) | M | 3 | 0 |
| Real read receipts (Sprint 4) | M | 3 | 3 |
| **Total** | | **~24 days** | **~28 days** |

**Calendar timeline (parallel: 1 backend + 1 Flutter engineer, focused)**: ~6 weeks compressed, ~12 weeks at normal cadence with reviews and testing.

**Schedule-risk concentration**: PTO + Gusto (Gap 6) holds ~30% of backend day-count. Splitting it into ledger v1 (Sprint 3) and Gusto export v2 (Sprint 4) keeps it from bottlenecking the rest.

---

## 6. Pre-Flight Checks (before Sprint 1 kicks off)

Two cheap verifications that affect Sprint 1 estimates:

1. **Availability endpoint shape**: confirm `MobileRequestController::setAvailability()` does full-replace (single POST with all 7 days) vs. patch (7 separate POSTs). Affects Flutter UX significantly.
2. **`scheduleTimePunches.punchType` column type**: if it's a strict `ENUM` lacking `breakStart`/`breakEnd`, add 1 day to Sprint 1 for an `ALTER TABLE` migration via `userforsting/conductor`. If `VARCHAR`, no schema work needed.

---

## 7. Strategic Positioning Statement

After this 6-sprint plan, the sales pitch becomes:

> "We do everything WhenIWork does — plus AI-powered scheduling, iOS Live Activity clock-ins, sales-correlated demand overlays, and we own your POS. One platform, one bill, one support team."

The biggest unlock is not the feature parity itself. It is removing the "we integrate with WhenIWork" caveat from sales conversations. Stores currently buying BuyerKiosk + WhenIWork pay two vendors and reconcile two sources of truth. After Sprint 3, that compound cost goes away and BuyerKiosk's POS-native moat finally shows up in the scheduling sales motion.

---

## Appendix A — File Pointers (For Drill-Down)

**Backend — Scheduling**
- Routes: `userfrosting/routes/scheduling.php`, `userforsting/routes/mobile-scheduling.php`, `userforsting/routes/admin/scheduling.php`
- Controllers: `userforsting/src/BuyerKiosk/Scheduling/Controllers/{Scheduling,ApprovalQueue,Template,OverlayData,ShiftTask}Controller.php`
- Mobile: `userforsting/src/BuyerKiosk/MobileScheduling/Controllers/{MobileSchedule,MobileClock}Controller.php`
- AI: `userforsting/src/BuyerKiosk/Scheduling/AiScheduling/`
- Labor cost: `userforsting/src/BuyerKiosk/Scheduling/Services/LaborCostCalculator.php`
- Open shifts: `userforsting/src/BuyerKiosk/MobileScheduling/Services/OpenShiftService.php`
- Geofence: `userforsting/src/BuyerKiosk/MobileScheduling/Services/GeofenceService.php`

**Backend — Migrations**
- Core schema: `userforsting/migrations/input/20251220_013_*.json`
- Spec 022 (availability/swaps/time-off): `20251222_022_*.json`
- Spec 025 (templates + RRULE): `20260104_025_*.json`
- AI/solver: `20260108_*.json`

**Mobile — Team app** (`/Users/rvanvuren/Projects/buyerkiosk-team`)
- Schedule view: `lib/presentation/screens/schedule/schedule_screen.dart`
- Clock provider: `lib/presentation/providers/clock_provider.dart`
- Open shifts: `lib/presentation/screens/open_shifts/open_shifts_screen.dart`
- Time-off + swap requests: `lib/presentation/screens/requests/`
- Chat: `lib/presentation/screens/chat/chat_screen.dart`
- Offline queue: `lib/core/services/offline_queue_service.dart`

**Mobile — Live app** (`/Users/rvanvuren/Projects/buyerkiosk-live-flutter`)
- Weekly schedule: `lib/presentation/screens/scheduling/schedule/weekly_schedule_screen.dart`
- Shift form: `lib/presentation/screens/scheduling/shifts/shift_form_screen.dart`
- Who's working: `lib/presentation/screens/whos_working_screen.dart`
- Punch management: `lib/presentation/screens/punch_management_screen.dart`
- Labor cost: `lib/presentation/screens/labor_cost_screen.dart`
- Pending requests: `lib/presentation/screens/pending_requests_screen.dart`
- Dashboard: `lib/presentation/screens/scheduling_dashboard_screen.dart`

**WhenIWork integration (current)**
- Provider: `userforsting/src/BuyerKiosk/Workbook/WhenIWorkSchedule.php`
- Homebase stub: `userforsting/src/BuyerKiosk/Workbook/HomebaseSchedule.php`
- Resolver: `ScheduleManager::resolveProvider()`

---

## Appendix B — Sources

- BuyerKiosk codebase exploration across web + both Flutter apps (May 2026)
- https://wheniwork.com/
- https://wheniwork.com/team-messaging
- https://wheniwork.com/features/time-clock
- https://wheniwork.com/features/employee-scheduling-software
- https://wheniwork.com/solutions/labor-forecasting-software
- https://help.wheniwork.com/articles/how-openshifts-work/
- https://help.wheniwork.com/articles/using-the-dashboard-scheduling-and-attendance-computer/
- https://help.wheniwork.com/article-categories/integrations/
