mvc-kit 2.13.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
  3. package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
  4. package/agent-config/lib/install-claude.mjs +8 -2
  5. package/examples/primitive/channel.ts +109 -0
  6. package/examples/primitive/collection.ts +118 -0
  7. package/examples/primitive/controller.ts +118 -0
  8. package/examples/primitive/counter.ts +108 -0
  9. package/examples/primitive/env.d.ts +1 -0
  10. package/examples/primitive/eventbus.ts +77 -0
  11. package/examples/primitive/feed.ts +162 -0
  12. package/examples/primitive/model.ts +82 -0
  13. package/examples/primitive/pagination.ts +91 -0
  14. package/examples/primitive/pending.ts +189 -0
  15. package/examples/primitive/persistent-collection.ts +116 -0
  16. package/examples/primitive/resource.ts +114 -0
  17. package/examples/primitive/selection.ts +96 -0
  18. package/examples/primitive/sorting.ts +112 -0
  19. package/examples/primitive/timer.ts +58 -0
  20. package/examples/primitive/trackable.ts +225 -0
  21. package/examples/primitive/tsconfig.json +20 -0
  22. package/examples/primitive/viewmodel-service.ts +161 -0
  23. package/examples/react/AuthExample/index.html +12 -0
  24. package/examples/react/AuthExample/src/App.tsx +29 -0
  25. package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
  26. package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
  27. package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
  28. package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
  29. package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
  30. package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
  31. package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
  32. package/examples/react/AuthExample/src/env.d.ts +10 -0
  33. package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
  34. package/examples/react/AuthExample/src/main.tsx +10 -0
  35. package/examples/react/AuthExample/src/mock/api.ts +78 -0
  36. package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
  37. package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
  38. package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
  39. package/examples/react/AuthExample/src/styles.css +445 -0
  40. package/examples/react/AuthExample/src/types/auth.ts +12 -0
  41. package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
  42. package/examples/react/AuthExample/tsconfig.json +22 -0
  43. package/examples/react/AuthExample/vite.config.ts +18 -0
  44. package/examples/react/ComplexApp/index.html +12 -0
  45. package/examples/react/ComplexApp/src/App.tsx +17 -0
  46. package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
  47. package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
  48. package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
  49. package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
  50. package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
  51. package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
  52. package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
  53. package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
  54. package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
  55. package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
  56. package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
  57. package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
  58. package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
  59. package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
  60. package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
  61. package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
  62. package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
  63. package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
  64. package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
  65. package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
  66. package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
  67. package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
  68. package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
  69. package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
  70. package/examples/react/ComplexApp/src/env.d.ts +10 -0
  71. package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
  72. package/examples/react/ComplexApp/src/main.tsx +10 -0
  73. package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
  74. package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
  75. package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
  76. package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
  77. package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
  78. package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
  79. package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
  80. package/examples/react/ComplexApp/src/styles.css +463 -0
  81. package/examples/react/ComplexApp/src/types/activity.ts +8 -0
  82. package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
  83. package/examples/react/ComplexApp/src/types/social.ts +8 -0
  84. package/examples/react/ComplexApp/src/types/users.ts +6 -0
  85. package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
  86. package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
  87. package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
  88. package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
  89. package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
  90. package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
  91. package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
  92. package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
  93. package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
  94. package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
  95. package/examples/react/ComplexApp/tsconfig.json +22 -0
  96. package/examples/react/ComplexApp/vite.config.ts +18 -0
  97. package/examples/react/FullApp/index.html +12 -0
  98. package/examples/react/FullApp/src/App.tsx +28 -0
  99. package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
  100. package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
  101. package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
  102. package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
  103. package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
  104. package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
  105. package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
  106. package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
  107. package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
  108. package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
  109. package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
  110. package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
  111. package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
  112. package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
  113. package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
  114. package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
  115. package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
  116. package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
  117. package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
  118. package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
  119. package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
  120. package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
  121. package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
  122. package/examples/react/FullApp/src/env.d.ts +10 -0
  123. package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
  124. package/examples/react/FullApp/src/main.tsx +10 -0
  125. package/examples/react/FullApp/src/mock/delay.ts +21 -0
  126. package/examples/react/FullApp/src/mock/locations.ts +76 -0
  127. package/examples/react/FullApp/src/mock/messages.ts +237 -0
  128. package/examples/react/FullApp/src/mock/users.ts +84 -0
  129. package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
  130. package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
  131. package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
  132. package/examples/react/FullApp/src/services/AuthService.ts +18 -0
  133. package/examples/react/FullApp/src/services/LocationService.ts +23 -0
  134. package/examples/react/FullApp/src/services/MessageService.ts +65 -0
  135. package/examples/react/FullApp/src/services/UserService.ts +23 -0
  136. package/examples/react/FullApp/src/styles.css +767 -0
  137. package/examples/react/FullApp/src/types/conversation.ts +7 -0
  138. package/examples/react/FullApp/src/types/location.ts +12 -0
  139. package/examples/react/FullApp/src/types/message.ts +7 -0
  140. package/examples/react/FullApp/src/types/user.ts +10 -0
  141. package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
  142. package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
  143. package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
  144. package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
  145. package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
  146. package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
  147. package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
  148. package/examples/react/FullApp/tsconfig.json +22 -0
  149. package/examples/react/FullApp/vite.config.ts +18 -0
  150. package/examples/react/WorkerApp/index.html +12 -0
  151. package/examples/react/WorkerApp/src/App.tsx +24 -0
  152. package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
  153. package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
  154. package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
  155. package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
  156. package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
  157. package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
  158. package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
  159. package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
  160. package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
  161. package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
  162. package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
  163. package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
  164. package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
  165. package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
  166. package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
  167. package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
  168. package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
  169. package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
  170. package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
  171. package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
  172. package/examples/react/WorkerApp/src/env.d.ts +10 -0
  173. package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
  174. package/examples/react/WorkerApp/src/main.tsx +10 -0
  175. package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
  176. package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
  177. package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
  178. package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
  179. package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
  180. package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
  181. package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
  182. package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
  183. package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
  184. package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
  185. package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
  186. package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
  187. package/examples/react/WorkerApp/src/styles.css +756 -0
  188. package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
  189. package/examples/react/WorkerApp/src/types/message.ts +7 -0
  190. package/examples/react/WorkerApp/src/types/shift.ts +13 -0
  191. package/examples/react/WorkerApp/src/types/site.ts +8 -0
  192. package/examples/react/WorkerApp/src/types/worker.ts +8 -0
  193. package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
  194. package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
  195. package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
  196. package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
  197. package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
  198. package/examples/react/WorkerApp/tsconfig.json +22 -0
  199. package/examples/react/WorkerApp/vite.config.ts +18 -0
  200. package/package.json +4 -2
@@ -0,0 +1,98 @@
1
+ import { useLocal } from 'mvc-kit/react';
2
+ import { ShiftViewModel } from '../../viewmodels/ShiftViewModel';
3
+ import { ShiftTimer } from './ShiftTimer';
4
+ import { SiteSelector } from './SiteSelector';
5
+ import { PendingBanner } from '../shared/PendingBanner';
6
+ import { Spinner } from '../shared/Spinner';
7
+
8
+ export function ShiftPage() {
9
+ const [state, vm] = useLocal(ShiftViewModel, { selectedSiteId: null, now: Date.now() });
10
+ const { loading } = vm.async.load;
11
+
12
+ return (
13
+ <div>
14
+ <h1 className="page-title">My Shift</h1>
15
+
16
+ <PendingBanner
17
+ pending={vm.pending}
18
+ label="shift operations"
19
+ renderEntry={entry => (
20
+ <span>{entry.meta?.action} at {entry.meta?.siteName} — {entry.error}</span>
21
+ )}
22
+ />
23
+
24
+ {loading && <Spinner />}
25
+
26
+ {!loading && !vm.isOnShift && (
27
+ <div className="card">
28
+ <h2 style={{ marginBottom: '1rem' }}>Clock In</h2>
29
+ <p style={{ color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
30
+ Select a work site and clock in to start your shift.
31
+ </p>
32
+ <SiteSelector
33
+ sites={vm.availableSites}
34
+ selectedId={state.selectedSiteId}
35
+ onSelect={id => vm.selectSite(id)}
36
+ />
37
+ <button
38
+ className="btn btn-primary"
39
+ disabled={!state.selectedSiteId || vm.hasPendingOps}
40
+ onClick={() => vm.clockIn()}
41
+ >
42
+ {vm.hasPendingOps ? 'Processing...' : 'Clock In'}
43
+ </button>
44
+ </div>
45
+ )}
46
+
47
+ {!loading && vm.isOnShift && (
48
+ <div className="card">
49
+ <ShiftTimer
50
+ shiftTime={vm.formattedShiftTime}
51
+ breakTime={vm.formattedBreakTime}
52
+ siteName={vm.currentSiteName}
53
+ isOnBreak={vm.isOnBreak}
54
+ />
55
+
56
+ <div className="shift-actions">
57
+ {vm.isOnBreak ? (
58
+ <button
59
+ className="btn btn-primary"
60
+ onClick={() => vm.endBreak()}
61
+ disabled={vm.hasPendingOps}
62
+ >
63
+ End Break
64
+ </button>
65
+ ) : (
66
+ <button
67
+ className="btn btn-secondary"
68
+ onClick={() => vm.startBreak()}
69
+ disabled={vm.hasPendingOps}
70
+ >
71
+ Start Break
72
+ </button>
73
+ )}
74
+
75
+ <button
76
+ className="btn btn-danger"
77
+ onClick={() => vm.clockOut()}
78
+ disabled={vm.hasPendingOps}
79
+ >
80
+ Clock Out
81
+ </button>
82
+ </div>
83
+
84
+ {vm.hasFailedOps && (
85
+ <div style={{ marginTop: '1rem' }}>
86
+ <button className="btn btn-sm btn-primary" onClick={() => vm.retryAll()}>
87
+ Retry Failed
88
+ </button>
89
+ <button className="btn btn-sm btn-secondary" style={{ marginLeft: '0.5rem' }} onClick={() => vm.dismissAll()}>
90
+ Dismiss
91
+ </button>
92
+ </div>
93
+ )}
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,24 @@
1
+ interface ShiftTimerProps {
2
+ shiftTime: string;
3
+ breakTime: string;
4
+ siteName: string;
5
+ isOnBreak: boolean;
6
+ }
7
+
8
+ export function ShiftTimer({ shiftTime, breakTime, siteName, isOnBreak }: ShiftTimerProps) {
9
+ return (
10
+ <div className="shift-timer">
11
+ <div className="shift-timer-site">{siteName}</div>
12
+ <div className="shift-timer-clock">
13
+ <div className="timer-value">{shiftTime}</div>
14
+ <div className="timer-label">Shift Duration</div>
15
+ </div>
16
+ <div className="shift-timer-break">
17
+ <span className={`badge ${isOnBreak ? 'badge-maintenance' : 'badge-inactive'}`}>
18
+ {isOnBreak ? 'On Break' : 'Working'}
19
+ </span>
20
+ <span className="timer-break-value">Break: {breakTime}</span>
21
+ </div>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,27 @@
1
+ import type { SiteState } from '../../types/site';
2
+
3
+ interface SiteSelectorProps {
4
+ sites: SiteState[];
5
+ selectedId: string | null;
6
+ onSelect: (id: string) => void;
7
+ }
8
+
9
+ export function SiteSelector({ sites, selectedId, onSelect }: SiteSelectorProps) {
10
+ return (
11
+ <div className="form-group">
12
+ <label className="form-label">Work Site</label>
13
+ <select
14
+ className="form-select"
15
+ value={selectedId ?? ''}
16
+ onChange={e => onSelect(e.target.value)}
17
+ >
18
+ <option value="">Select a site...</option>
19
+ {sites.map(site => (
20
+ <option key={site.id} value={site.id}>
21
+ {site.name} — {site.address}
22
+ </option>
23
+ ))}
24
+ </select>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,61 @@
1
+ import type { SiteState } from '../../types/site';
2
+
3
+ interface SiteFiltersProps {
4
+ search: string;
5
+ typeFilter: 'all' | SiteState['type'];
6
+ statusFilter: 'all' | SiteState['status'];
7
+ onSearchChange: (v: string) => void;
8
+ onTypeFilterChange: (v: 'all' | SiteState['type']) => void;
9
+ onStatusFilterChange: (v: 'all' | SiteState['status']) => void;
10
+ }
11
+
12
+ export function SiteFilters({
13
+ search,
14
+ typeFilter,
15
+ statusFilter,
16
+ onSearchChange,
17
+ onTypeFilterChange,
18
+ onStatusFilterChange,
19
+ }: SiteFiltersProps) {
20
+ return (
21
+ <div className="filters">
22
+ <div className="filter-group">
23
+ <label className="filter-label">Search</label>
24
+ <input
25
+ className="filter-input"
26
+ type="text"
27
+ value={search}
28
+ onChange={e => onSearchChange(e.target.value)}
29
+ placeholder="Search sites..."
30
+ />
31
+ </div>
32
+ <div className="filter-group">
33
+ <label className="filter-label">Type</label>
34
+ <select
35
+ className="filter-select"
36
+ value={typeFilter}
37
+ onChange={e => onTypeFilterChange(e.target.value as any)}
38
+ >
39
+ <option value="all">All Types</option>
40
+ <option value="residential">Residential</option>
41
+ <option value="commercial">Commercial</option>
42
+ <option value="industrial">Industrial</option>
43
+ <option value="infrastructure">Infrastructure</option>
44
+ </select>
45
+ </div>
46
+ <div className="filter-group">
47
+ <label className="filter-label">Status</label>
48
+ <select
49
+ className="filter-select"
50
+ value={statusFilter}
51
+ onChange={e => onStatusFilterChange(e.target.value as any)}
52
+ >
53
+ <option value="all">All Statuses</option>
54
+ <option value="active">Active</option>
55
+ <option value="paused">Paused</option>
56
+ <option value="completed">Completed</option>
57
+ </select>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,102 @@
1
+ import { useLocal, DataTable } from 'mvc-kit/react';
2
+ import type { Column } from 'mvc-kit/react';
3
+ import { SitesViewModel } from '../../viewmodels/SitesViewModel';
4
+ import { SiteFilters } from './SiteFilters';
5
+ import { Spinner } from '../shared/Spinner';
6
+ import { ErrorBanner } from '../shared/ErrorBanner';
7
+ import type { SiteState } from '../../types/site';
8
+
9
+ const columns: Column<SiteState>[] = [
10
+ {
11
+ key: 'name',
12
+ header: 'Name',
13
+ render: site => <span style={{ fontWeight: 500 }}>{site.name}</span>,
14
+ sortable: true,
15
+ },
16
+ {
17
+ key: 'type',
18
+ header: 'Type',
19
+ render: site => (
20
+ <span className={`badge badge-${site.type === 'residential' ? 'admin' : site.type === 'commercial' ? 'manager' : 'member'}`}>
21
+ {site.type}
22
+ </span>
23
+ ),
24
+ },
25
+ {
26
+ key: 'address',
27
+ header: 'Address',
28
+ render: site => site.address,
29
+ sortable: true,
30
+ },
31
+ {
32
+ key: 'status',
33
+ header: 'Status',
34
+ render: site => <span className={`badge badge-${site.status}`}>{site.status}</span>,
35
+ sortable: true,
36
+ },
37
+ {
38
+ key: 'workerCount',
39
+ header: 'Workers',
40
+ render: site => site.workerCount,
41
+ sortable: true,
42
+ },
43
+ ];
44
+
45
+ export function SitesPage() {
46
+ const [state, vm] = useLocal(SitesViewModel, {
47
+ search: '',
48
+ typeFilter: 'all',
49
+ statusFilter: 'all',
50
+ });
51
+ const { loading, error } = vm.async.load;
52
+ // console.log('vm.paged', vm.paged)
53
+ return (
54
+ <div>
55
+ <h1 className="page-title">Work Sites</h1>
56
+
57
+ <SiteFilters
58
+ search={state.search}
59
+ typeFilter={state.typeFilter}
60
+ statusFilter={state.statusFilter}
61
+ onSearchChange={v => vm.setSearch(v)}
62
+ onTypeFilterChange={vm.setTypeFilter}
63
+ onStatusFilterChange={v => vm.setStatusFilter(v)}
64
+ />
65
+
66
+ <div className="results-bar">
67
+ <span>Showing {vm.filteredCount} of {vm.total}</span>
68
+ </div>
69
+
70
+ {vm.selection.hasSelection && (
71
+ <div className="selection-bar">
72
+ <span>{vm.selection.count} selected</span>
73
+ </div>
74
+ )}
75
+
76
+ <DataTable
77
+ items={vm.paged}
78
+ columns={columns}
79
+ loading={loading}
80
+ error={error}
81
+ sort={vm.sorting}
82
+ selection={vm.selection}
83
+ pagination={vm.pagination}
84
+ paginationTotal={vm.filteredCount}
85
+ renderLoading={() => <Spinner />}
86
+ renderError={msg => <ErrorBanner message={msg} />}
87
+ renderEmpty={() => <div className="empty-state">No sites match your filters.</div>}
88
+ renderSortIndicator={({ active, direction }) => (
89
+ <span>{active ? (direction === 'asc' ? ' ↑' : ' ↓') : ''}</span>
90
+ )}
91
+ renderPagination={info => (
92
+ <div className="pagination-bar">
93
+ <button disabled={!info.hasPrev} onClick={info.goPrev}>Prev</button>
94
+ <span>Page {info.page} of {info.pageCount}</span>
95
+ <button disabled={!info.hasNext} onClick={info.goNext}>Next</button>
96
+ </div>
97
+ )}
98
+ className="table-container"
99
+ />
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,10 @@
1
+ declare const __MVC_KIT_DEV__: boolean;
2
+
3
+ declare module 'react-dom/client' {
4
+ import type { ReactNode } from 'react';
5
+ interface Root {
6
+ render(children: ReactNode): void;
7
+ unmount(): void;
8
+ }
9
+ export function createRoot(container: Element): Root;
10
+ }
@@ -0,0 +1,7 @@
1
+ import { EventBus } from 'mvc-kit';
2
+
3
+ export interface AppEvents {
4
+ 'toast:show': { message: string; severity: 'success' | 'error' | 'info' };
5
+ }
6
+
7
+ export class AppEventBus extends EventBus<AppEvents> {}
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './App';
4
+ import './styles.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,38 @@
1
+ export interface MockWSConfig {
2
+ minInterval: number;
3
+ maxInterval: number;
4
+ generator: () => any;
5
+ }
6
+
7
+ export class MockWebSocket {
8
+ private timer: ReturnType<typeof setTimeout> | null = null;
9
+ private closed = false;
10
+
11
+ constructor(private config: MockWSConfig) {}
12
+
13
+ connect(onMessage: (data: any) => void, signal?: AbortSignal): void {
14
+ if (signal?.aborted) return;
15
+ signal?.addEventListener('abort', () => this.close(), { once: true });
16
+ this.scheduleNext(onMessage);
17
+ }
18
+
19
+ close(): void {
20
+ if (this.closed) return;
21
+ this.closed = true;
22
+ if (this.timer !== null) {
23
+ clearTimeout(this.timer);
24
+ this.timer = null;
25
+ }
26
+ }
27
+
28
+ private scheduleNext(onMessage: (data: any) => void): void {
29
+ if (this.closed) return;
30
+ const { minInterval, maxInterval, generator } = this.config;
31
+ const interval = minInterval + Math.random() * (maxInterval - minInterval);
32
+ this.timer = setTimeout(() => {
33
+ if (this.closed) return;
34
+ onMessage(generator());
35
+ this.scheduleNext(onMessage);
36
+ }, interval);
37
+ }
38
+ }
@@ -0,0 +1,31 @@
1
+ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
2
+ return new Promise((resolve, reject) => {
3
+ if (signal?.aborted) {
4
+ reject(new DOMException('Aborted', 'AbortError'));
5
+ return;
6
+ }
7
+
8
+ const timer = setTimeout(resolve, ms);
9
+
10
+ signal?.addEventListener('abort', () => {
11
+ clearTimeout(timer);
12
+ reject(new DOMException('Aborted', 'AbortError'));
13
+ }, { once: true });
14
+ });
15
+ }
16
+
17
+ export async function mockFetch<T>(data: T, ms?: number, signal?: AbortSignal): Promise<T> {
18
+ const jitter = 200 + Math.random() * 300;
19
+ await delay(ms ?? jitter, signal);
20
+ return data;
21
+ }
22
+
23
+ /** ~30% failure rate to simulate unreliable network */
24
+ export async function mockFetchUnreliable<T>(data: T, ms?: number, signal?: AbortSignal): Promise<T> {
25
+ const jitter = 200 + Math.random() * 400;
26
+ await delay(ms ?? jitter, signal);
27
+ if (Math.random() < 0.3) {
28
+ throw new Error('Network error: request failed');
29
+ }
30
+ return data;
31
+ }
@@ -0,0 +1,120 @@
1
+ import type { MessageState } from '../types/message';
2
+ import type { ConversationState } from '../types/conversation';
3
+ import type { FeedPage } from 'mvc-kit';
4
+ import { mockFetch, mockFetchUnreliable } from './delay';
5
+
6
+ const PAGE_SIZE = 15;
7
+
8
+ // --- Seed conversations ---
9
+ const MOCK_CONVERSATIONS: ConversationState[] = [
10
+ { id: 'conv1', participantIds: ['w1', 'w2'], lastMessage: 'On my way to the site', unreadCount: 2, updatedAt: Date.now() - 60000 },
11
+ { id: 'conv2', participantIds: ['w1', 'w3', 'w4'], lastMessage: 'Need more supplies at Riverside', unreadCount: 0, updatedAt: Date.now() - 120000 },
12
+ { id: 'conv3', participantIds: ['w1', 'w5'], lastMessage: 'Break time?', unreadCount: 1, updatedAt: Date.now() - 300000 },
13
+ { id: 'conv4', participantIds: ['w1', 'w6'], lastMessage: 'Wiring specs updated', unreadCount: 0, updatedAt: Date.now() - 600000 },
14
+ ];
15
+
16
+ // --- Seed messages ---
17
+ const allMessages = new Map<string, MessageState[]>();
18
+
19
+ function seedMessages(convId: string, participants: string[]): MessageState[] {
20
+ const texts = [
21
+ 'Hey, are you at the site yet?',
22
+ 'Just got here. Parking is a mess.',
23
+ 'I\'ll be there in 10 minutes.',
24
+ 'Can you check the electrical panel on floor 3?',
25
+ 'Done. Everything looks good.',
26
+ 'Great work today!',
27
+ 'Need more 2x4s for the framing.',
28
+ 'I\'ll call the supplier.',
29
+ 'Safety inspection is at 2pm.',
30
+ 'Got it, I\'ll make sure the crew knows.',
31
+ 'Weather looks bad tomorrow.',
32
+ 'We should cover the exposed sections.',
33
+ 'Lunch run — want anything?',
34
+ 'Just a coffee, thanks.',
35
+ 'The foreman wants a progress report by EOD.',
36
+ 'On it. Almost done with section B.',
37
+ 'New blueprints came in.',
38
+ 'I\'ll review them tonight.',
39
+ 'Nice, the pour came out clean.',
40
+ 'Couldn\'t have done it without the crew.',
41
+ ];
42
+
43
+ const messages: MessageState[] = [];
44
+ const count = 20 + Math.floor(Math.random() * 10);
45
+ const baseTime = Date.now() - count * 60000;
46
+
47
+ for (let i = 0; i < count; i++) {
48
+ messages.push({
49
+ id: `${convId}-msg${i}`,
50
+ conversationId: convId,
51
+ senderId: participants[i % participants.length]!,
52
+ text: texts[i % texts.length]!,
53
+ sentAt: baseTime + i * 60000,
54
+ });
55
+ }
56
+ return messages;
57
+ }
58
+
59
+ for (const conv of MOCK_CONVERSATIONS) {
60
+ allMessages.set(conv.id, seedMessages(conv.id, conv.participantIds));
61
+ }
62
+
63
+ // --- API ---
64
+
65
+ export async function fetchConversations(workerId: string, signal?: AbortSignal): Promise<ConversationState[]> {
66
+ const convs = MOCK_CONVERSATIONS.filter(c => c.participantIds.includes(workerId));
67
+ return mockFetch(convs, undefined, signal);
68
+ }
69
+
70
+ export async function fetchMessages(
71
+ conversationId: string,
72
+ signal?: AbortSignal,
73
+ opts?: { cursor?: string | null },
74
+ ): Promise<FeedPage<MessageState>> {
75
+ const messages = allMessages.get(conversationId) ?? [];
76
+ // Sort newest first for cursor-based pagination
77
+ const sorted = [...messages].sort((a, b) => b.sentAt - a.sentAt);
78
+
79
+ let startIndex = 0;
80
+ if (opts?.cursor) {
81
+ const cursorIdx = sorted.findIndex(m => m.id === opts.cursor);
82
+ if (cursorIdx >= 0) startIndex = cursorIdx + 1;
83
+ }
84
+
85
+ const page = sorted.slice(startIndex, startIndex + PAGE_SIZE);
86
+ const hasMore = startIndex + PAGE_SIZE < sorted.length;
87
+ const cursor = page.length > 0 ? page[page.length - 1]!.id : null;
88
+
89
+ return mockFetch({ items: page.reverse(), hasMore, cursor }, undefined, signal);
90
+ }
91
+
92
+ export async function sendMessage(
93
+ conversationId: string,
94
+ senderId: string,
95
+ text: string,
96
+ signal?: AbortSignal,
97
+ ): Promise<MessageState> {
98
+ const message: MessageState = {
99
+ id: `${conversationId}-msg${Date.now()}`,
100
+ conversationId,
101
+ senderId,
102
+ text,
103
+ sentAt: Date.now(),
104
+ };
105
+
106
+ // Store in mock database
107
+ const existing = allMessages.get(conversationId) ?? [];
108
+ existing.push(message);
109
+ allMessages.set(conversationId, existing);
110
+
111
+ // Update conversation
112
+ const conv = MOCK_CONVERSATIONS.find(c => c.id === conversationId);
113
+ if (conv) {
114
+ conv.lastMessage = text;
115
+ conv.updatedAt = Date.now();
116
+ }
117
+
118
+ // Unreliable — ~30% chance of failure to showcase Pending retries
119
+ return mockFetchUnreliable(message, undefined, signal);
120
+ }
@@ -0,0 +1,57 @@
1
+ import type { ShiftState } from '../types/shift';
2
+ import { mockFetchUnreliable } from './delay';
3
+
4
+ let shiftStore: ShiftState | null = null;
5
+
6
+ export async function fetchCurrentShift(workerId: string, signal?: AbortSignal): Promise<ShiftState | null> {
7
+ return mockFetchUnreliable(shiftStore, undefined, signal);
8
+ }
9
+
10
+ export async function clockIn(workerId: string, siteId: string, signal?: AbortSignal): Promise<ShiftState> {
11
+ const shift: ShiftState = {
12
+ id: `shift-${Date.now()}`,
13
+ workerId,
14
+ siteId,
15
+ clockIn: Date.now(),
16
+ clockOut: null,
17
+ breaks: [],
18
+ };
19
+ shiftStore = shift;
20
+ return mockFetchUnreliable(shift, undefined, signal);
21
+ }
22
+
23
+ export async function clockOut(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
24
+ if (!shiftStore || shiftStore.id !== shiftId) {
25
+ throw new Error('No active shift');
26
+ }
27
+ // End any open break
28
+ const breaks = shiftStore.breaks.map(b =>
29
+ b.end === null ? { ...b, end: Date.now() } : b,
30
+ );
31
+ shiftStore = { ...shiftStore, clockOut: Date.now(), breaks };
32
+ const result = shiftStore;
33
+ shiftStore = null;
34
+ return mockFetchUnreliable(result, undefined, signal);
35
+ }
36
+
37
+ export async function startBreak(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
38
+ if (!shiftStore || shiftStore.id !== shiftId) {
39
+ throw new Error('No active shift');
40
+ }
41
+ shiftStore = {
42
+ ...shiftStore,
43
+ breaks: [...shiftStore.breaks, { start: Date.now(), end: null }],
44
+ };
45
+ return mockFetchUnreliable(shiftStore, undefined, signal);
46
+ }
47
+
48
+ export async function endBreak(shiftId: string, signal?: AbortSignal): Promise<ShiftState> {
49
+ if (!shiftStore || shiftStore.id !== shiftId) {
50
+ throw new Error('No active shift');
51
+ }
52
+ const breaks = shiftStore.breaks.map(b =>
53
+ b.end === null ? { ...b, end: Date.now() } : b,
54
+ );
55
+ shiftStore = { ...shiftStore, breaks };
56
+ return mockFetchUnreliable(shiftStore, undefined, signal);
57
+ }
@@ -0,0 +1,14 @@
1
+ import type { SiteState } from '../types/site';
2
+
3
+ export const MOCK_SITES: SiteState[] = [
4
+ { id: 's1', name: 'Riverside Apartments', type: 'residential', status: 'active', address: '120 River St', workerCount: 8 },
5
+ { id: 's2', name: 'Downtown Office Tower', type: 'commercial', status: 'active', address: '500 Main Ave', workerCount: 15 },
6
+ { id: 's3', name: 'Harbor Industrial Park', type: 'industrial', status: 'active', address: '80 Harbor Rd', workerCount: 12 },
7
+ { id: 's4', name: 'Maple Ridge Homes', type: 'residential', status: 'active', address: '45 Maple Ln', workerCount: 6 },
8
+ { id: 's5', name: 'City Bridge Repair', type: 'infrastructure', status: 'paused', address: 'Bridge St', workerCount: 0 },
9
+ { id: 's6', name: 'Tech Campus Phase 2', type: 'commercial', status: 'active', address: '200 Innovation Dr', workerCount: 20 },
10
+ { id: 's7', name: 'Waterfront Condos', type: 'residential', status: 'completed', address: '10 Bay Walk', workerCount: 0 },
11
+ { id: 's8', name: 'Factory Retrofit', type: 'industrial', status: 'active', address: '300 Industrial Blvd', workerCount: 9 },
12
+ { id: 's9', name: 'Highway Extension', type: 'infrastructure', status: 'active', address: 'Route 9 North', workerCount: 25 },
13
+ { id: 's10', name: 'Community Center', type: 'commercial', status: 'paused', address: '15 Park Ave', workerCount: 0 },
14
+ ];
@@ -0,0 +1,12 @@
1
+ import type { WorkerState } from '../types/worker';
2
+
3
+ export const MOCK_WORKERS: WorkerState[] = [
4
+ { id: 'w1', name: 'Marcus Johnson', email: 'marcus@example.com', role: 'foreman', status: 'available', avatar: 'MJ' },
5
+ { id: 'w2', name: 'Sarah Chen', email: 'sarah@example.com', role: 'electrician', status: 'available', avatar: 'SC' },
6
+ { id: 'w3', name: 'David Kowalski', email: 'david@example.com', role: 'plumber', status: 'on-shift', avatar: 'DK' },
7
+ { id: 'w4', name: 'Ana Rodriguez', email: 'ana@example.com', role: 'carpenter', status: 'on-shift', avatar: 'AR' },
8
+ { id: 'w5', name: 'James O\'Brien', email: 'james@example.com', role: 'laborer', status: 'on-break', avatar: 'JO' },
9
+ { id: 'w6', name: 'Priya Patel', email: 'priya@example.com', role: 'electrician', status: 'offline', avatar: 'PP' },
10
+ ];
11
+
12
+ export const CURRENT_WORKER = MOCK_WORKERS[0]!;
@@ -0,0 +1,17 @@
1
+ import { Model } from 'mvc-kit';
2
+ import type { ValidationErrors } from 'mvc-kit';
3
+
4
+ export interface ComposeMessageState {
5
+ text: string;
6
+ }
7
+
8
+ export class ComposeMessageModel extends Model<ComposeMessageState> {
9
+ setText(text: string) { this.set({ text }); }
10
+
11
+ protected validate(state: ComposeMessageState): ValidationErrors<ComposeMessageState> {
12
+ const errors: Partial<Record<keyof ComposeMessageState, string>> = {};
13
+ if (!state.text.trim()) errors.text = 'Message cannot be empty';
14
+ if (state.text.length > 500) errors.text = 'Message must be under 500 characters';
15
+ return errors;
16
+ }
17
+ }
@@ -0,0 +1,10 @@
1
+ import { Resource } from 'mvc-kit';
2
+ import type { ConversationState } from '../types/conversation';
3
+ import { fetchConversations } from '../mock/messages';
4
+
5
+ export class ConversationsResource extends Resource<ConversationState> {
6
+ async loadAll(workerId: string) {
7
+ const data = await fetchConversations(workerId, this.disposeSignal);
8
+ this.reset(data);
9
+ }
10
+ }