mvc-kit 2.13.0 → 2.13.2

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 (218) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
  3. package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
  4. package/agent-config/lib/install-claude.mjs +39 -110
  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
  201. /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
  202. /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
  203. /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
  204. /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
  205. /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
  206. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
  207. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
  208. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
  209. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
  210. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
  211. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
  212. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
  213. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
  214. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
  215. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
  216. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
  217. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
  218. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
@@ -0,0 +1,7 @@
1
+ export interface ConversationState {
2
+ id: string;
3
+ participantIds: string[];
4
+ lastMessage: string;
5
+ lastMessageAt: string;
6
+ unreadCount: number;
7
+ }
@@ -0,0 +1,12 @@
1
+ export interface LocationState {
2
+ id: string;
3
+ name: string;
4
+ type: 'office' | 'warehouse' | 'retail';
5
+ status: 'active' | 'inactive' | 'maintenance';
6
+ city: string;
7
+ state: string;
8
+ address: string;
9
+ capacity: number;
10
+ managerId: string;
11
+ createdAt: string;
12
+ }
@@ -0,0 +1,7 @@
1
+ export interface MessageState {
2
+ id: string;
3
+ conversationId: string;
4
+ senderId: string;
5
+ text: string;
6
+ sentAt: string;
7
+ }
@@ -0,0 +1,10 @@
1
+ export interface UserState {
2
+ id: string;
3
+ firstName: string;
4
+ lastName: string;
5
+ email: string;
6
+ role: 'admin' | 'manager' | 'member';
7
+ status: 'active' | 'inactive';
8
+ avatarUrl: string;
9
+ createdAt: string;
10
+ }
@@ -0,0 +1,51 @@
1
+ import { ViewModel, singleton, isAbortError, classifyError } from 'mvc-kit';
2
+ import type { UserState } from '../types/user';
3
+ import { AuthService } from '../services/AuthService';
4
+ import { AppEventBus } from '../events/AppEventBus';
5
+
6
+ interface AuthState {
7
+ user: UserState | null;
8
+ isAuthenticated: boolean;
9
+ }
10
+
11
+ interface AuthEvents {
12
+ loginFailed: { message: string };
13
+ }
14
+
15
+ export class AuthViewModel extends ViewModel<AuthState, AuthEvents> {
16
+ static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
17
+
18
+ // --- Private fields ---
19
+ private authService = singleton(AuthService);
20
+ private bus = singleton(AppEventBus);
21
+
22
+ // --- Computed getters ---
23
+ get displayName(): string {
24
+ const { user } = this.state;
25
+ return user ? `${user.firstName} ${user.lastName}` : '';
26
+ }
27
+
28
+ get initials(): string {
29
+ const { user } = this.state;
30
+ return user ? `${user.firstName[0]}${user.lastName[0]}` : '';
31
+ }
32
+
33
+ // --- Actions ---
34
+ async login(email: string, password: string) {
35
+ try {
36
+ const user = await this.authService.login(email, password, this.disposeSignal);
37
+ this.set({ user, isAuthenticated: true });
38
+ this.bus.emit('toast:show', { message: `Welcome, ${user.firstName}!`, severity: 'success' });
39
+ } catch (e) {
40
+ if (!isAbortError(e)) {
41
+ this.emit('loginFailed', { message: classifyError(e).message });
42
+ }
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ logout() {
48
+ this.set({ user: null, isAuthenticated: false });
49
+ this.bus.emit('toast:show', { message: 'Logged out', severity: 'info' });
50
+ }
51
+ }
@@ -0,0 +1,89 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { ConversationState } from '../types/conversation';
3
+ import { MessageService } from '../services/MessageService';
4
+ import { ConversationsCollection } from '../collections/ConversationsCollection';
5
+ import { UsersResource } from '../resources/UsersResource';
6
+ import { AuthViewModel } from './AuthViewModel';
7
+
8
+ export interface ConversationDisplay extends ConversationState {
9
+ displayName: string;
10
+ }
11
+
12
+ interface ConversationsState {
13
+ search: string;
14
+ selectedId: string | null;
15
+ currentUserId: string;
16
+ }
17
+
18
+ export class ConversationsViewModel extends ViewModel<ConversationsState> {
19
+ // --- Private fields ---
20
+ private service = singleton(MessageService);
21
+ private collection = singleton(ConversationsCollection);
22
+ private users = singleton(UsersResource);
23
+
24
+ // --- Computed getters ---
25
+ get items(): ConversationState[] {
26
+ return this.collection.items as ConversationState[];
27
+ }
28
+
29
+ get filtered(): ConversationDisplay[] {
30
+ const { search, currentUserId } = this.state;
31
+ let result = this.items;
32
+
33
+ if (search) {
34
+ const q = search.toLowerCase();
35
+ result = result.filter(conv =>
36
+ conv.participantIds.some(id => {
37
+ const user = this.users.get(id);
38
+ return user
39
+ ? `${user.firstName} ${user.lastName}`.toLowerCase().includes(q)
40
+ : false;
41
+ }),
42
+ );
43
+ }
44
+
45
+ return result.map(conv => ({
46
+ ...conv,
47
+ displayName: conv.participantIds
48
+ .filter(id => id !== currentUserId)
49
+ .map(id => {
50
+ const user = this.users.get(id);
51
+ return user ? `${user.firstName} ${user.lastName}` : 'Unknown';
52
+ })
53
+ .join(', '),
54
+ }));
55
+ }
56
+
57
+ get selected(): ConversationDisplay | undefined {
58
+ return this.filtered.find(c => c.id === this.state.selectedId);
59
+ }
60
+
61
+ get totalUnread(): number {
62
+ return this.items.reduce((sum, c) => sum + c.unreadCount, 0);
63
+ }
64
+
65
+ // --- Lifecycle ---
66
+ protected onInit() {
67
+ const auth = singleton(AuthViewModel);
68
+ const currentUserId = auth.state.user?.id ?? '';
69
+ this.set({ currentUserId });
70
+
71
+ if (this.collection.length === 0) this.load();
72
+ }
73
+
74
+ // --- Actions ---
75
+ async load() {
76
+ const conversations = await this.service.getConversations(
77
+ this.state.currentUserId,
78
+ this.disposeSignal,
79
+ );
80
+ this.collection.reset(conversations);
81
+ }
82
+
83
+ selectConversation(id: string) {
84
+ this.set({ selectedId: id });
85
+ }
86
+
87
+ // --- Setters ---
88
+ setSearch(search: string) { this.set({ search }); }
89
+ }
@@ -0,0 +1,56 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { UserState } from '../types/user';
3
+ import type { LocationState } from '../types/location';
4
+ import { LocationService } from '../services/LocationService';
5
+ import { UsersResource } from '../resources/UsersResource';
6
+ import { LocationsCollection } from '../collections/LocationsCollection';
7
+
8
+ export class DashboardViewModel extends ViewModel {
9
+ // --- Private fields ---
10
+ private users = singleton(UsersResource);
11
+ private locationService = singleton(LocationService);
12
+ private locationsCollection = singleton(LocationsCollection);
13
+
14
+ // --- Computed getters ---
15
+ get totalUsers(): number {
16
+ return this.users.length;
17
+ }
18
+
19
+ get activeUsers(): number {
20
+ return (this.users.items as UserState[]).filter(u => u.status === 'active').length;
21
+ }
22
+
23
+ get totalLocations(): number {
24
+ return this.locationsCollection.length;
25
+ }
26
+
27
+ get activeLocations(): number {
28
+ return (this.locationsCollection.items as LocationState[]).filter(l => l.status === 'active').length;
29
+ }
30
+
31
+ get usersByRole(): Record<string, number> {
32
+ const counts: Record<string, number> = {};
33
+ for (const u of this.users.items as UserState[]) {
34
+ counts[u.role] = (counts[u.role] ?? 0) + 1;
35
+ }
36
+ return counts;
37
+ }
38
+
39
+ get recentUsers(): UserState[] {
40
+ return [...(this.users.items as UserState[])]
41
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
42
+ .slice(0, 5);
43
+ }
44
+
45
+ // --- Lifecycle ---
46
+ protected onInit() {
47
+ if (this.users.length === 0) this.users.loadAll();
48
+ if (this.locationsCollection.length === 0) this.loadLocations();
49
+ }
50
+
51
+ // --- Actions ---
52
+ async loadLocations() {
53
+ const locations = await this.locationService.getAll(this.disposeSignal);
54
+ this.locationsCollection.reset(locations);
55
+ }
56
+ }
@@ -0,0 +1,81 @@
1
+ import { ViewModel, singleton, isAbortError } from 'mvc-kit';
2
+ import type { LocationState } from '../types/location';
3
+ import { LocationService } from '../services/LocationService';
4
+ import { LocationsCollection } from '../collections/LocationsCollection';
5
+ import { UsersResource } from '../resources/UsersResource';
6
+ import { AppEventBus } from '../events/AppEventBus';
7
+ import { LocationFormModel } from '../models/LocationFormModel';
8
+
9
+ interface ProfileState {
10
+ location: LocationState | null;
11
+ locationId: string;
12
+ }
13
+
14
+ interface ProfileEvents {
15
+ saved: { id: string };
16
+ }
17
+
18
+ export class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
19
+ // --- Private fields ---
20
+ private service = singleton(LocationService);
21
+ private collection = singleton(LocationsCollection);
22
+ private users = singleton(UsersResource);
23
+ private bus = singleton(AppEventBus);
24
+ public model: LocationFormModel | null = null;
25
+
26
+ // --- Computed getters ---
27
+ get managerName(): string {
28
+ const { location } = this.state;
29
+ if (!location) return '';
30
+ const manager = this.users.get(location.managerId);
31
+ return manager ? `${manager.firstName} ${manager.lastName}` : 'Unknown';
32
+ }
33
+
34
+ // --- Lifecycle ---
35
+ protected onInit() {
36
+ this.load();
37
+ }
38
+
39
+ protected onDispose() {
40
+ this.model?.dispose();
41
+ }
42
+
43
+ // --- Actions ---
44
+ async load() {
45
+ const location = await this.service.getById(
46
+ this.state.locationId,
47
+ this.disposeSignal,
48
+ );
49
+ this.model = new LocationFormModel({
50
+ name: location.name,
51
+ type: location.type,
52
+ city: location.city,
53
+ state: location.state,
54
+ address: location.address,
55
+ capacity: location.capacity,
56
+ });
57
+ this.set({ location });
58
+ }
59
+
60
+ async save() {
61
+ if (!this.model || !this.model.valid) return;
62
+
63
+ try {
64
+ const updated = await this.service.update(
65
+ this.state.locationId,
66
+ this.model.state,
67
+ this.disposeSignal,
68
+ );
69
+ this.collection.update(this.state.locationId, updated);
70
+ this.model.commit();
71
+ this.set({ location: updated });
72
+ this.emit('saved', { id: updated.id });
73
+ this.bus.emit('toast:show', { message: 'Location saved', severity: 'success' });
74
+ } catch (e) {
75
+ if (!isAbortError(e)) {
76
+ this.bus.emit('toast:show', { message: 'Failed to save location', severity: 'error' });
77
+ }
78
+ throw e;
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,113 @@
1
+ import { ViewModel, singleton, Sorting, Pagination, Selection } from 'mvc-kit';
2
+ import type { LocationState } from '../types/location';
3
+ import { LocationService } from '../services/LocationService';
4
+ import { LocationsCollection } from '../collections/LocationsCollection';
5
+ import { AppEventBus } from '../events/AppEventBus';
6
+
7
+ interface LocationsState {
8
+ search: string;
9
+ typeFilter: 'all' | LocationState['type'];
10
+ statusFilter: 'all' | LocationState['status'];
11
+ }
12
+
13
+ export class LocationsViewModel extends ViewModel<LocationsState> {
14
+ // --- Private fields ---
15
+ private service = singleton(LocationService);
16
+ collection = singleton(LocationsCollection);
17
+ private bus = singleton(AppEventBus);
18
+
19
+ readonly sorting = new Sorting<LocationState>({ sorts: [{ key: 'name', direction: 'asc' }] });
20
+ readonly pagination = new Pagination({ pageSize: 10 });
21
+ readonly selection = new Selection<string>();
22
+
23
+ // --- Computed getters ---
24
+ get items(): LocationState[] {
25
+ return this.collection.items as LocationState[];
26
+ }
27
+
28
+ get filtered(): LocationState[] {
29
+ const { search, typeFilter, statusFilter } = this.state;
30
+ let result = this.items;
31
+
32
+ if (search) {
33
+ const q = search.toLowerCase();
34
+ result = result.filter(loc =>
35
+ loc.name.toLowerCase().includes(q) ||
36
+ loc.city.toLowerCase().includes(q),
37
+ );
38
+ }
39
+ if (typeFilter !== 'all') {
40
+ result = result.filter(loc => loc.type === typeFilter);
41
+ }
42
+ if (statusFilter !== 'all') {
43
+ result = result.filter(loc => loc.status === statusFilter);
44
+ }
45
+ return result;
46
+ }
47
+
48
+ get sorted(): LocationState[] {
49
+ return this.sorting.apply(this.filtered);
50
+ }
51
+
52
+ get paged(): LocationState[] {
53
+ return this.pagination.apply(this.sorted);
54
+ }
55
+
56
+ get total(): number {
57
+ return this.items.length;
58
+ }
59
+
60
+ get filteredCount(): number {
61
+ return this.filtered.length;
62
+ }
63
+
64
+ get hasResults(): boolean {
65
+ return this.filtered.length > 0;
66
+ }
67
+
68
+ get isEmpty(): boolean {
69
+ return this.total > 0 && !this.hasResults;
70
+ }
71
+
72
+ get selectedItems(): LocationState[] {
73
+ return this.selection.selectedFrom(this.filtered, loc => loc.id);
74
+ }
75
+
76
+ // --- Lifecycle ---
77
+ protected onInit() {
78
+ if (this.collection.length === 0) this.load();
79
+ }
80
+
81
+ // --- Actions ---
82
+ async load() {
83
+ const data = await this.service.getAll(this.disposeSignal);
84
+ this.collection.reset(data);
85
+ }
86
+
87
+ async refresh() {
88
+ await this.load();
89
+ this.bus.emit('toast:show', { message: 'Locations refreshed', severity: 'info' });
90
+ }
91
+
92
+ async bulkToggleStatus() {
93
+ const items = this.selectedItems;
94
+ if (items.length === 0) return;
95
+
96
+ for (const loc of items) {
97
+ const newStatus = loc.status === 'active' ? 'inactive' : 'active';
98
+ const updated = await this.service.update(loc.id, { status: newStatus }, this.disposeSignal);
99
+ this.collection.update(loc.id, updated);
100
+ }
101
+
102
+ this.selection.clear();
103
+ this.bus.emit('toast:show', {
104
+ message: `Toggled status for ${items.length} location(s)`,
105
+ severity: 'success',
106
+ });
107
+ }
108
+
109
+ // --- Setters ---
110
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
111
+ setTypeFilter(typeFilter: LocationsState['typeFilter']) { this.set({ typeFilter }); this.pagination.reset(); }
112
+ setStatusFilter(statusFilter: LocationsState['statusFilter']) { this.set({ statusFilter }); this.pagination.reset(); }
113
+ }
@@ -0,0 +1,83 @@
1
+ import { ViewModel, singleton, Feed } from 'mvc-kit';
2
+ import type { MessageState } from '../types/message';
3
+ import { MessageService } from '../services/MessageService';
4
+ import { AuthViewModel } from './AuthViewModel';
5
+
6
+ interface ThreadState {
7
+ draft: string;
8
+ }
9
+
10
+ interface ThreadEvents {
11
+ messageSent: { conversationId: string };
12
+ }
13
+
14
+ export class MessageThreadViewModel extends ViewModel<ThreadState, ThreadEvents> {
15
+ // --- Private fields ---
16
+ private service = singleton(MessageService);
17
+ private _loadController: AbortController | null = null;
18
+ private _conversationId: string = '';
19
+
20
+ readonly feed = new Feed<MessageState>();
21
+
22
+ // --- Computed getters ---
23
+ get sortedMessages(): MessageState[] {
24
+ return [...this.feed.items].sort(
25
+ (a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime(),
26
+ );
27
+ }
28
+
29
+ get canSend(): boolean {
30
+ return this.state.draft.trim().length > 0;
31
+ }
32
+
33
+ // --- Actions ---
34
+
35
+ /** Load messages for a conversation. Cancels any in-flight load via AbortSignal.any(). */
36
+ async loadConversation(conversationId: string) {
37
+ // Cancel previous load (per-call cancellation)
38
+ this._loadController?.abort();
39
+ this._loadController = new AbortController();
40
+ this._conversationId = conversationId;
41
+
42
+ this.feed.reset();
43
+
44
+ const page = await this.service.getMessages(
45
+ conversationId,
46
+ AbortSignal.any([this.disposeSignal, this._loadController.signal]),
47
+ );
48
+ this.feed.appendPage(page);
49
+ }
50
+
51
+ async loadOlderMessages() {
52
+ if (!this.feed.hasMore || !this._conversationId) return;
53
+
54
+ const page = await this.service.getMessages(
55
+ this._conversationId,
56
+ this.disposeSignal,
57
+ { cursor: this.feed.cursor },
58
+ );
59
+ this.feed.appendPage(page);
60
+ }
61
+
62
+ async sendMessage(conversationId: string) {
63
+ const text = this.state.draft.trim();
64
+ if (!text) return;
65
+
66
+ const auth = singleton(AuthViewModel);
67
+ const senderId = auth.state.user?.id ?? '';
68
+
69
+ const message = await this.service.sendMessage(
70
+ conversationId,
71
+ senderId,
72
+ text,
73
+ this.disposeSignal,
74
+ );
75
+
76
+ this.feed.push(message);
77
+ this.set({ draft: '' });
78
+ this.emit('messageSent', { conversationId });
79
+ }
80
+
81
+ // --- Setters ---
82
+ setDraft(draft: string) { this.set({ draft }); }
83
+ }
@@ -0,0 +1,88 @@
1
+ import { ViewModel, singleton, Sorting, Pagination } from 'mvc-kit';
2
+ import type { UserState } from '../types/user';
3
+ import { UserService } from '../services/UserService';
4
+ import { UsersResource } from '../resources/UsersResource';
5
+
6
+ interface UsersState {
7
+ search: string;
8
+ roleFilter: 'all' | UserState['role'];
9
+ statusFilter: 'all' | UserState['status'];
10
+ }
11
+
12
+ export class UsersViewModel extends ViewModel<UsersState> {
13
+ // --- Private fields ---
14
+ private service = singleton(UserService);
15
+ private users = singleton(UsersResource);
16
+
17
+ readonly sorting = new Sorting<UserState>({ sorts: [{ key: 'firstName', direction: 'asc' }] });
18
+ readonly pagination = new Pagination({ pageSize: 25 });
19
+
20
+ // --- Computed getters ---
21
+ get items(): UserState[] {
22
+ return this.users.items as UserState[];
23
+ }
24
+
25
+ get filtered(): UserState[] {
26
+ const { search, roleFilter, statusFilter } = this.state;
27
+ let result = this.items;
28
+
29
+ if (search) {
30
+ const q = search.toLowerCase();
31
+ result = result.filter(u =>
32
+ u.firstName.toLowerCase().includes(q) ||
33
+ u.lastName.toLowerCase().includes(q) ||
34
+ u.email.toLowerCase().includes(q),
35
+ );
36
+ }
37
+ if (roleFilter !== 'all') {
38
+ result = result.filter(u => u.role === roleFilter);
39
+ }
40
+ if (statusFilter !== 'all') {
41
+ result = result.filter(u => u.status === statusFilter);
42
+ }
43
+ return result;
44
+ }
45
+
46
+ get sorted(): UserState[] {
47
+ return this.sorting.apply(this.filtered);
48
+ }
49
+
50
+ get paged(): UserState[] {
51
+ return this.pagination.apply(this.sorted);
52
+ }
53
+
54
+ get total(): number {
55
+ return this.items.length;
56
+ }
57
+
58
+ get filteredCount(): number {
59
+ return this.filtered.length;
60
+ }
61
+
62
+ get hasResults(): boolean {
63
+ return this.filtered.length > 0;
64
+ }
65
+
66
+ get isEmpty(): boolean {
67
+ return this.total > 0 && !this.hasResults;
68
+ }
69
+
70
+ // --- Lifecycle ---
71
+ protected onInit() {
72
+ if (this.users.length === 0) this.users.loadAll();
73
+ }
74
+
75
+ // --- Actions ---
76
+ async toggleStatus(id: string) {
77
+ const user = this.users.get(id);
78
+ if (!user) return;
79
+ const newStatus = user.status === 'active' ? 'inactive' : 'active';
80
+ const updated = await this.service.update(id, { status: newStatus }, this.disposeSignal);
81
+ this.users.update(id, updated);
82
+ }
83
+
84
+ // --- Setters ---
85
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
86
+ setRoleFilter(roleFilter: UsersState['roleFilter']) { this.set({ roleFilter }); this.pagination.reset(); }
87
+ setStatusFilter(statusFilter: UsersState['statusFilter']) { this.set({ statusFilter }); this.pagination.reset(); }
88
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": false,
13
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
14
+ "types": [],
15
+ "baseUrl": ".",
16
+ "paths": {
17
+ "mvc-kit": ["../../../src/index.ts"],
18
+ "mvc-kit/react": ["../../../src/react/index.ts"]
19
+ }
20
+ },
21
+ "include": ["src"]
22
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'node:path';
3
+
4
+ export default defineConfig({
5
+ root: import.meta.dirname,
6
+ define: {
7
+ __MVC_KIT_DEV__: true,
8
+ },
9
+ resolve: {
10
+ alias: {
11
+ 'mvc-kit/react': resolve(import.meta.dirname, '../../../src/react/index.ts'),
12
+ 'mvc-kit': resolve(import.meta.dirname, '../../../src/index.ts'),
13
+ },
14
+ },
15
+ server: {
16
+ port: 3000,
17
+ },
18
+ });
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>mvc-kit Worker App</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,24 @@
1
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
2
+ import { AppShell } from './components/layout/AppShell';
3
+ import { LoginPage } from './components/auth/LoginPage';
4
+ import { ShiftPage } from './components/shift/ShiftPage';
5
+ import { SitesPage } from './components/sites/SitesPage';
6
+ import { MessagingPage } from './components/messaging/MessagingPage';
7
+ import { Toast } from './components/shared/Toast';
8
+
9
+ export function App() {
10
+ return (
11
+ <BrowserRouter>
12
+ <Routes>
13
+ <Route path="/login" element={<LoginPage />} />
14
+ <Route element={<AppShell />}>
15
+ <Route path="/shift" element={<ShiftPage />} />
16
+ <Route path="/sites" element={<SitesPage />} />
17
+ <Route path="/messaging" element={<MessagingPage />} />
18
+ </Route>
19
+ <Route path="*" element={<Navigate to="/shift" replace />} />
20
+ </Routes>
21
+ <Toast />
22
+ </BrowserRouter>
23
+ );
24
+ }