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,7 @@
1
+ export interface ConversationState {
2
+ id: string;
3
+ participantIds: string[];
4
+ lastMessage: string;
5
+ unreadCount: number;
6
+ updatedAt: number;
7
+ }
@@ -0,0 +1,7 @@
1
+ export interface MessageState {
2
+ id: string;
3
+ conversationId: string;
4
+ senderId: string;
5
+ text: string;
6
+ sentAt: number;
7
+ }
@@ -0,0 +1,13 @@
1
+ export interface BreakEntry {
2
+ start: number;
3
+ end: number | null;
4
+ }
5
+
6
+ export interface ShiftState {
7
+ id: string;
8
+ workerId: string;
9
+ siteId: string;
10
+ clockIn: number;
11
+ clockOut: number | null;
12
+ breaks: BreakEntry[];
13
+ }
@@ -0,0 +1,8 @@
1
+ export interface SiteState {
2
+ id: string;
3
+ name: string;
4
+ type: 'residential' | 'commercial' | 'industrial' | 'infrastructure';
5
+ status: 'active' | 'paused' | 'completed';
6
+ address: string;
7
+ workerCount: number;
8
+ }
@@ -0,0 +1,8 @@
1
+ export interface WorkerState {
2
+ id: string;
3
+ name: string;
4
+ email: string;
5
+ role: 'foreman' | 'electrician' | 'plumber' | 'carpenter' | 'laborer';
6
+ status: 'available' | 'on-shift' | 'on-break' | 'offline';
7
+ avatar: string;
8
+ }
@@ -0,0 +1,41 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { WorkerState } from '../types/worker';
3
+ import { MOCK_WORKERS } from '../mock/workers';
4
+ import { mockFetch } from '../mock/delay';
5
+ import { AppEventBus } from '../events/AppEventBus';
6
+
7
+ interface AuthState {
8
+ worker: WorkerState | null;
9
+ isAuthenticated: boolean;
10
+ }
11
+
12
+ export class AuthViewModel extends ViewModel<AuthState> {
13
+ static DEFAULT_STATE: AuthState = { worker: null, isAuthenticated: false };
14
+
15
+ // --- Private fields ---
16
+ private bus = singleton(AppEventBus);
17
+
18
+ // --- Computed getters ---
19
+ get displayName(): string {
20
+ const { worker } = this.state;
21
+ return worker ? worker.name : '';
22
+ }
23
+
24
+ get initials(): string {
25
+ const { worker } = this.state;
26
+ return worker ? worker.avatar : '';
27
+ }
28
+
29
+ // --- Actions ---
30
+ async login(email: string) {
31
+ const worker = MOCK_WORKERS.find(w => w.email === email) ?? MOCK_WORKERS[0]!;
32
+ await mockFetch(null, 300);
33
+ this.set({ worker, isAuthenticated: true });
34
+ this.bus.emit('toast:show', { message: `Welcome, ${worker.name}!`, severity: 'success' });
35
+ }
36
+
37
+ logout() {
38
+ this.set({ worker: null, isAuthenticated: false });
39
+ this.bus.emit('toast:show', { message: 'Logged out', severity: 'info' });
40
+ }
41
+ }
@@ -0,0 +1,83 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { ConversationState } from '../types/conversation';
3
+ import { ConversationsResource } from '../resources/ConversationsResource';
4
+ import { WorkersResource } from '../resources/WorkersResource';
5
+ import { AuthViewModel } from './AuthViewModel';
6
+
7
+ export interface ConversationDisplay extends ConversationState {
8
+ displayName: string;
9
+ }
10
+
11
+ interface ConversationsViewState {
12
+ search: string;
13
+ selectedId: string | null;
14
+ currentWorkerId: string;
15
+ }
16
+
17
+ export class ConversationsViewModel extends ViewModel<ConversationsViewState> {
18
+ // --- Private fields ---
19
+ private resource = singleton(ConversationsResource);
20
+ private workers = singleton(WorkersResource);
21
+
22
+ // --- Computed getters ---
23
+ get items(): ConversationState[] {
24
+ return this.resource.items as ConversationState[];
25
+ }
26
+
27
+ get filtered(): ConversationDisplay[] {
28
+ const { search, currentWorkerId } = this.state;
29
+ let result = this.items;
30
+
31
+ if (search) {
32
+ const q = search.toLowerCase();
33
+ result = result.filter(conv =>
34
+ conv.participantIds.some(id => {
35
+ const worker = this.workers.get(id);
36
+ return worker ? worker.name.toLowerCase().includes(q) : false;
37
+ }),
38
+ );
39
+ }
40
+
41
+ return result.map(conv => ({
42
+ ...conv,
43
+ displayName: conv.participantIds
44
+ .filter(id => id !== currentWorkerId)
45
+ .map(id => {
46
+ const worker = this.workers.get(id);
47
+ return worker ? worker.name : 'Unknown';
48
+ })
49
+ .join(', '),
50
+ }));
51
+ }
52
+
53
+ get selected(): ConversationDisplay | undefined {
54
+ return this.filtered.find(c => c.id === this.state.selectedId);
55
+ }
56
+
57
+ get totalUnread(): number {
58
+ return this.items.reduce((sum, c) => sum + c.unreadCount, 0);
59
+ }
60
+
61
+ // --- Lifecycle ---
62
+ protected onInit() {
63
+ const auth = singleton(AuthViewModel);
64
+ const currentWorkerId = auth.state.worker?.id ?? '';
65
+ this.set({ currentWorkerId });
66
+
67
+ if (this.workers.length === 0) this.workers.loadAll();
68
+ if (this.resource.length === 0) this.load();
69
+ }
70
+
71
+ // --- Actions ---
72
+ async load() {
73
+ const { currentWorkerId } = this.state;
74
+ await this.resource.loadAll(currentWorkerId);
75
+ }
76
+
77
+ selectConversation(id: string) {
78
+ this.set({ selectedId: id });
79
+ }
80
+
81
+ // --- Setters ---
82
+ setSearch(search: string) { this.set({ search }); }
83
+ }
@@ -0,0 +1,113 @@
1
+ import { ViewModel, singleton, Feed } from 'mvc-kit';
2
+ import type { MessageState } from '../types/message';
3
+ import { MessagesResource } from '../resources/MessagesResource';
4
+ import { MessagingChannel } from '../channels/MessagingChannel';
5
+ import { WorkersResource } from '../resources/WorkersResource';
6
+ import { AuthViewModel } from './AuthViewModel';
7
+
8
+ interface ThreadState {}
9
+
10
+ interface ThreadEvents {
11
+ messageSent: { conversationId: string };
12
+ }
13
+
14
+ export class MessageThreadViewModel extends ViewModel<ThreadState, ThreadEvents> {
15
+ // --- Private fields ---
16
+ private resource = singleton(MessagesResource);
17
+ private channel = singleton(MessagingChannel);
18
+ private workers = singleton(WorkersResource);
19
+ private _loadController: AbortController | null = null;
20
+ private _conversationId: string = '';
21
+
22
+ readonly feed = new Feed<MessageState>();
23
+
24
+ // --- Computed getters ---
25
+ get sortedMessages(): MessageState[] {
26
+ return [...this.feed.items].sort((a, b) => a.sentAt - b.sentAt);
27
+ }
28
+
29
+ get pending() {
30
+ return this.resource.pending;
31
+ }
32
+
33
+ get hasPendingMessages(): boolean {
34
+ return this.resource.pending.hasPending;
35
+ }
36
+
37
+ get hasFailedMessages(): boolean {
38
+ return this.resource.pending.hasFailed;
39
+ }
40
+
41
+ // --- Lifecycle ---
42
+ protected onInit() {
43
+ this.listenTo(this.channel, 'newMessage', (msg) => {
44
+ if (msg.conversationId === this._conversationId) {
45
+ this.feed.push(msg);
46
+ }
47
+ });
48
+ this.channel.connect();
49
+ }
50
+
51
+ // --- Actions ---
52
+ async loadConversation(conversationId: string) {
53
+ this._loadController?.abort();
54
+ this._loadController = new AbortController();
55
+ this._conversationId = conversationId;
56
+
57
+ this.feed.reset();
58
+
59
+ const page = await this.resource.loadMessages(
60
+ conversationId,
61
+ AbortSignal.any([this.disposeSignal, this._loadController.signal]),
62
+ );
63
+ this.feed.appendPage(page);
64
+ }
65
+
66
+ async loadOlderMessages() {
67
+ if (!this.feed.hasMore || !this._conversationId) return;
68
+
69
+ const page = await this.resource.loadMessages(
70
+ this._conversationId,
71
+ this.disposeSignal,
72
+ { cursor: this.feed.cursor },
73
+ );
74
+ this.feed.appendPage(page);
75
+ }
76
+
77
+ sendMessage(conversationId: string, text: string, recipientName: string) {
78
+ const trimmed = text.trim();
79
+ if (!trimmed) return;
80
+
81
+ const auth = singleton(AuthViewModel);
82
+ const senderId = auth.state.worker?.id ?? '';
83
+ const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
84
+
85
+ // Optimistic: add to feed immediately
86
+ const optimisticMsg: MessageState = {
87
+ id: tempId,
88
+ conversationId,
89
+ senderId,
90
+ text: trimmed,
91
+ sentAt: Date.now(),
92
+ };
93
+ this.feed.push(optimisticMsg);
94
+
95
+ // Enqueue via resource Pending for retry — meta provides UI context
96
+ this.resource.enqueueSend(tempId, conversationId, senderId, trimmed, recipientName);
97
+
98
+ this.emit('messageSent', { conversationId });
99
+ }
100
+
101
+ retryMessage(id: string) {
102
+ this.resource.pending.retry(id);
103
+ }
104
+
105
+ dismissMessage(id: string) {
106
+ this.resource.pending.dismiss(id);
107
+ }
108
+
109
+ getSenderName(senderId: string): string {
110
+ const worker = this.workers.get(senderId);
111
+ return worker ? worker.name : 'Unknown';
112
+ }
113
+ }
@@ -0,0 +1,147 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { ShiftState } from '../types/shift';
3
+ import type { SiteState } from '../types/site';
4
+ import { ShiftResource } from '../resources/ShiftResource';
5
+ import { SitesResource } from '../resources/SitesResource';
6
+ import { AuthViewModel } from './AuthViewModel';
7
+
8
+ interface ShiftViewState {
9
+ selectedSiteId: string | null;
10
+ now: number;
11
+ }
12
+
13
+ export class ShiftViewModel extends ViewModel<ShiftViewState> {
14
+ // --- Private fields ---
15
+ private shiftResource = singleton(ShiftResource);
16
+ private sites = singleton(SitesResource);
17
+ private auth = singleton(AuthViewModel);
18
+ private _timer: ReturnType<typeof setInterval> | null = null;
19
+
20
+ // --- Computed getters ---
21
+ get currentShift(): ShiftState | null {
22
+ const items = this.shiftResource.items as ShiftState[];
23
+ return items.length > 0 ? items[0]! : null;
24
+ }
25
+
26
+ get isOnShift(): boolean {
27
+ return this.currentShift !== null && this.currentShift.clockOut === null;
28
+ }
29
+
30
+ get isOnBreak(): boolean {
31
+ const shift = this.currentShift;
32
+ if (!shift) return false;
33
+ return shift.breaks.some(b => b.end === null);
34
+ }
35
+
36
+ get shiftDuration(): number {
37
+ const shift = this.currentShift;
38
+ if (!shift) return 0;
39
+ const end = shift.clockOut ?? this.state.now;
40
+ return end - shift.clockIn;
41
+ }
42
+
43
+ get breakDuration(): number {
44
+ const shift = this.currentShift;
45
+ if (!shift) return 0;
46
+ return shift.breaks.reduce((sum, b) => {
47
+ const end = b.end ?? this.state.now;
48
+ return sum + (end - b.start);
49
+ }, 0);
50
+ }
51
+
52
+ get formattedShiftTime(): string {
53
+ return formatDuration(this.shiftDuration);
54
+ }
55
+
56
+ get formattedBreakTime(): string {
57
+ return formatDuration(this.breakDuration);
58
+ }
59
+
60
+ get currentSiteName(): string {
61
+ const shift = this.currentShift;
62
+ if (!shift) return '';
63
+ const site = this.sites.get(shift.siteId) as SiteState | undefined;
64
+ return site?.name ?? '';
65
+ }
66
+
67
+ get availableSites(): SiteState[] {
68
+ return (this.sites.items as SiteState[]).filter(s => s.status === 'active');
69
+ }
70
+
71
+ get pending() {
72
+ return this.shiftResource.pending;
73
+ }
74
+
75
+ get hasPendingOps(): boolean {
76
+ return this.shiftResource.pending.hasPending;
77
+ }
78
+
79
+ get hasFailedOps(): boolean {
80
+ return this.shiftResource.pending.hasFailed;
81
+ }
82
+
83
+ // --- Lifecycle ---
84
+ protected onInit() {
85
+ const workerId = this.auth.state.worker?.id;
86
+ if (workerId && this.shiftResource.length === 0) this.load();
87
+ if (this.sites.length === 0) this.sites.loadAll();
88
+
89
+ this._timer = setInterval(() => {
90
+ this.set({ now: Date.now() });
91
+ }, 1000);
92
+ this.addCleanup(() => {
93
+ if (this._timer) clearInterval(this._timer);
94
+ });
95
+ }
96
+
97
+ // --- Actions ---
98
+ async load() {
99
+ const workerId = this.auth.state.worker?.id;
100
+ if (!workerId) return;
101
+ await this.shiftResource.loadCurrent(workerId);
102
+ }
103
+
104
+ clockIn() {
105
+ const workerId = this.auth.state.worker?.id;
106
+ const siteId = this.state.selectedSiteId;
107
+ if (!workerId || !siteId) return;
108
+ this.shiftResource.clockIn(workerId, siteId, this.currentSiteName || siteId);
109
+ }
110
+
111
+ clockOut() {
112
+ const shift = this.currentShift;
113
+ if (!shift) return;
114
+ this.shiftResource.clockOut(shift.id, this.currentSiteName);
115
+ }
116
+
117
+ startBreak() {
118
+ const shift = this.currentShift;
119
+ if (!shift) return;
120
+ this.shiftResource.startBreak(shift.id, this.currentSiteName);
121
+ }
122
+
123
+ endBreak() {
124
+ const shift = this.currentShift;
125
+ if (!shift) return;
126
+ this.shiftResource.endBreak(shift.id, this.currentSiteName);
127
+ }
128
+
129
+ retryAll() {
130
+ this.shiftResource.pending.retryAll();
131
+ }
132
+
133
+ dismissAll() {
134
+ this.shiftResource.pending.dismissAll();
135
+ }
136
+
137
+ // --- Setters ---
138
+ selectSite(siteId: string) { this.set({ selectedSiteId: siteId }); }
139
+ }
140
+
141
+ function formatDuration(ms: number): string {
142
+ const totalSeconds = Math.floor(ms / 1000);
143
+ const hours = Math.floor(totalSeconds / 3600);
144
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
145
+ const seconds = totalSeconds % 60;
146
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
147
+ }
@@ -0,0 +1,82 @@
1
+ import { ViewModel, singleton, Sorting, Pagination, Selection } from 'mvc-kit';
2
+ import type { SiteState } from '../types/site';
3
+ import { SitesResource } from '../resources/SitesResource';
4
+
5
+ interface SitesViewState {
6
+ search: string;
7
+ typeFilter: 'all' | SiteState['type'];
8
+ statusFilter: 'all' | SiteState['status'];
9
+ }
10
+
11
+ export class SitesViewModel extends ViewModel<SitesViewState> {
12
+ // --- Private fields ---
13
+ private resource = singleton(SitesResource);
14
+
15
+ readonly sorting = new Sorting<SiteState>({ sorts: [{ key: 'name', direction: 'asc' }] });
16
+ readonly pagination = new Pagination({ pageSize: 10 });
17
+ readonly selection = new Selection<string>();
18
+
19
+ // --- Computed getters ---
20
+ get items(): SiteState[] {
21
+ return this.resource.items as SiteState[];
22
+ }
23
+
24
+ get filtered(): SiteState[] {
25
+ const { search, typeFilter, statusFilter } = this.state;
26
+ let result = this.items;
27
+
28
+ if (search) {
29
+ const q = search.toLowerCase();
30
+ result = result.filter(site =>
31
+ site.name.toLowerCase().includes(q) ||
32
+ site.address.toLowerCase().includes(q),
33
+ );
34
+ }
35
+ if (typeFilter !== 'all') {
36
+ result = result.filter(site => site.type === typeFilter);
37
+ }
38
+ if (statusFilter !== 'all') {
39
+ result = result.filter(site => site.status === statusFilter);
40
+ }
41
+ return result;
42
+ }
43
+
44
+ get sorted(): SiteState[] {
45
+ return this.sorting.apply(this.filtered);
46
+ }
47
+
48
+ get paged(): SiteState[] {
49
+ return this.pagination.apply(this.sorted);
50
+ }
51
+
52
+ get total(): number {
53
+ return this.items.length;
54
+ }
55
+
56
+ get filteredCount(): number {
57
+ return this.filtered.length;
58
+ }
59
+
60
+ get hasResults(): boolean {
61
+ return this.filtered.length > 0;
62
+ }
63
+
64
+ get selectedItems(): SiteState[] {
65
+ return this.selection.selectedFrom(this.filtered, site => site.id);
66
+ }
67
+
68
+ // --- Lifecycle ---
69
+ protected onInit() {
70
+ if (this.resource.length === 0) this.load();
71
+ }
72
+
73
+ // --- Actions ---
74
+ async load() {
75
+ await this.resource.loadAll();
76
+ }
77
+
78
+ // --- Setters ---
79
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
80
+ setTypeFilter(typeFilter: SitesViewState['typeFilter']) { this.set({ typeFilter }); this.pagination.reset(); }
81
+ setStatusFilter(statusFilter: SitesViewState['statusFilter']) { this.set({ statusFilter }); this.pagination.reset(); }
82
+ }
@@ -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: 3002,
17
+ },
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mvc-kit",
3
- "version": "2.13.0",
3
+ "version": "2.13.1",
4
4
  "description": "Zero-magic, class-based reactive ViewModel library",
5
5
  "type": "module",
6
6
  "main": "./dist/mvc-kit.cjs",
@@ -64,9 +64,11 @@
64
64
  "files": [
65
65
  "./mvc-kit-logo.jpg",
66
66
  "dist",
67
+ "examples",
67
68
  "src",
68
69
  "agent-config",
69
- "README.md"
70
+ "README.md",
71
+ "BEST_PRACTICES.md"
70
72
  ],
71
73
  "bin": {
72
74
  "mvc-kit-setup": "./agent-config/bin/setup.mjs"