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,114 @@
1
+ import { Resource, singleton, teardownAll } from 'mvc-kit';
2
+
3
+ // Resource: Collection + async tracking + lifecycle
4
+ //
5
+ // Use Resource when you need a shared data cache with built-in
6
+ // loading/error state for your API calls. It extends Collection
7
+ // with init()/dispose() lifecycle and automatic async method tracking.
8
+ //
9
+ // If you already have a typed API client (tRPC, GraphQL codegen, etc.),
10
+ // call it directly from the Resource — no Service wrapper needed.
11
+
12
+ // --- Entity type ---
13
+
14
+ interface Product {
15
+ id: string;
16
+ name: string;
17
+ price: number;
18
+ category: string;
19
+ }
20
+
21
+ // --- Resource definition ---
22
+
23
+ class ProductsResource extends Resource<Product> {
24
+ // All async methods are automatically tracked after init().
25
+ // Access loading/error state via resource.async.methodName
26
+
27
+ async loadAll() {
28
+ // Pass disposeSignal to cancel in-flight requests on teardown.
29
+ // In production, this would call your API client or fetch().
30
+ const products = await fakeFetch<Product[]>('/api/products', this.disposeSignal);
31
+ this.reset(products);
32
+ }
33
+
34
+ async loadByCategory(category: string) {
35
+ const products = await fakeFetch<Product[]>(
36
+ `/api/products?category=${category}`,
37
+ this.disposeSignal,
38
+ );
39
+ // upsert: add new items or replace existing ones by ID (preserves other items)
40
+ this.upsert(...products);
41
+ }
42
+
43
+ async create(data: Omit<Product, 'id'>) {
44
+ const product = await fakeFetch<Product>('/api/products', this.disposeSignal);
45
+ this.add(product);
46
+ return product;
47
+ }
48
+
49
+ // Lifecycle hook: called after init()
50
+ protected onInit() {
51
+ // Smart-init: only load if the collection is empty.
52
+ // Prevents redundant fetches when multiple consumers share the singleton.
53
+ if (this.length === 0) this.loadAll();
54
+ }
55
+ }
56
+
57
+ // --- Usage ---
58
+
59
+ const products = singleton(ProductsResource);
60
+ await products.init(); // wraps methods for async tracking, calls onInit()
61
+
62
+ // Async tracking — no manual loading/error flags needed
63
+ console.log('Loading:', products.async.loadAll.loading); // true (load started in onInit)
64
+
65
+ // Wait for load to complete
66
+ await products.loadAll();
67
+
68
+ console.log('Loading:', products.async.loadAll.loading); // false
69
+ console.log('Error:', products.async.loadAll.error); // null
70
+ console.log('Total:', products.length); // 3
71
+
72
+ // All Collection methods are available
73
+ console.log('Items:', products.items);
74
+ console.log('Get by id:', products.get('1')?.name);
75
+ console.log('Filter:', products.filter(p => p.price > 20).length);
76
+
77
+ // Subscribe to data changes (inherited from Collection)
78
+ products.subscribe((items) => {
79
+ console.log(`Products changed: ${items.length} items`);
80
+ });
81
+
82
+ // Subscribe to async state changes (loading/error)
83
+ products.subscribeAsync(() => {
84
+ console.log('Async state changed:', products.async.loadAll);
85
+ });
86
+
87
+ // Load more data — upsert merges without duplicates
88
+ await products.loadByCategory('electronics');
89
+
90
+ // Cleanup
91
+ products.dispose();
92
+ teardownAll();
93
+
94
+ // --- Fake API helper ---
95
+
96
+ function fakeFetch<T>(_url: string, signal?: AbortSignal): Promise<T> {
97
+ return new Promise((resolve, reject) => {
98
+ if (signal?.aborted) {
99
+ reject(signal.reason);
100
+ return;
101
+ }
102
+ const timer = setTimeout(() => {
103
+ resolve([
104
+ { id: '1', name: 'Widget', price: 9.99, category: 'gadgets' },
105
+ { id: '2', name: 'Gizmo', price: 24.99, category: 'gadgets' },
106
+ { id: '3', name: 'Doohickey', price: 49.99, category: 'electronics' },
107
+ ] as unknown as T);
108
+ }, 100);
109
+ signal?.addEventListener('abort', () => {
110
+ clearTimeout(timer);
111
+ reject(signal.reason);
112
+ }, { once: true });
113
+ });
114
+ }
@@ -0,0 +1,96 @@
1
+ import { Selection } from 'mvc-kit';
2
+
3
+ // Selection: Key-based selection set with toggle and select-all
4
+ //
5
+ // A subscribable helper that manages a Set of selected keys. Designed
6
+ // to be a property on a ViewModel — auto-tracked so ViewModel getters
7
+ // that read selection state recompute automatically.
8
+ //
9
+ // Methods are auto-bound, so they can be passed point-free as callbacks
10
+ // (e.g., onChange={selection.toggle}).
11
+
12
+ // --- Entity type ---
13
+
14
+ interface File {
15
+ id: string;
16
+ name: string;
17
+ size: number;
18
+ }
19
+
20
+ const files: File[] = [
21
+ { id: '1', name: 'report.pdf', size: 2400 },
22
+ { id: '2', name: 'photo.jpg', size: 5100 },
23
+ { id: '3', name: 'notes.txt', size: 120 },
24
+ { id: '4', name: 'backup.zip', size: 98000 },
25
+ ];
26
+
27
+ const allIds = files.map(f => f.id);
28
+
29
+ // --- Basic usage ---
30
+
31
+ const selection = new Selection<string>();
32
+
33
+ // Subscribe to selection changes
34
+ selection.subscribe(() => {
35
+ console.log('Selected:', [...selection.selected]);
36
+ });
37
+
38
+ // Read current state
39
+ console.log('Count:', selection.count); // 0
40
+ console.log('Has selection:', selection.hasSelection); // false
41
+
42
+ // --- Toggle ---
43
+
44
+ selection.toggle('1');
45
+ console.log('After toggle 1:', selection.isSelected('1')); // true
46
+
47
+ selection.toggle('3');
48
+ console.log('Count:', selection.count); // 2
49
+
50
+ selection.toggle('1'); // toggle off
51
+ console.log('After toggle 1 again:', selection.isSelected('1')); // false
52
+ console.log('Count:', selection.count); // 1
53
+
54
+ // --- Select / Deselect ---
55
+
56
+ selection.select('1', '2', '4');
57
+ console.log('After select:', selection.count); // 4 (3 already had item 3)
58
+
59
+ selection.deselect('2', '4');
60
+ console.log('After deselect:', selection.count); // 2 (items 1 and 3)
61
+
62
+ // --- Toggle all ---
63
+
64
+ // If not all are selected, selects all
65
+ selection.toggleAll(allIds);
66
+ console.log('After toggleAll (select):', selection.count); // 4
67
+
68
+ // If all are selected, deselects all
69
+ selection.toggleAll(allIds);
70
+ console.log('After toggleAll (deselect):', selection.count); // 0
71
+
72
+ // --- Set (atomic replace) ---
73
+
74
+ selection.set('2', '3');
75
+ console.log('After set:', [...selection.selected]); // ['2', '3']
76
+
77
+ // --- Filter items by selection ---
78
+
79
+ selection.set('1', '4');
80
+ const selectedFiles = selection.selectedFrom(files, f => f.id);
81
+ console.log('Selected files:', selectedFiles.map(f => f.name));
82
+ // ['report.pdf', 'backup.zip']
83
+
84
+ // --- Point-free callbacks ---
85
+
86
+ // Methods are auto-bound, so you can pass them directly:
87
+ // <input onChange={selection.toggle} />
88
+ // <button onClick={() => selection.toggleAll(allIds)} />
89
+ const toggleFn = selection.toggle; // no .bind() needed
90
+ toggleFn('3');
91
+ console.log('After point-free toggle:', selection.isSelected('3')); // true
92
+
93
+ // --- Clear ---
94
+
95
+ selection.clear();
96
+ console.log('After clear:', selection.count); // 0
@@ -0,0 +1,112 @@
1
+ import { Sorting } from 'mvc-kit';
2
+
3
+ // Sorting: Multi-column sort state with a comparator pipeline
4
+ //
5
+ // A subscribable helper that manages sort descriptors and applies them
6
+ // to arrays. Designed to be a property on a ViewModel — auto-tracked
7
+ // so ViewModel getters that read sorting state recompute automatically.
8
+ //
9
+ // Three-click cycle: unsorted → asc → desc → removed.
10
+
11
+ // --- Entity type ---
12
+
13
+ interface Employee {
14
+ id: string;
15
+ name: string;
16
+ department: string;
17
+ salary: number;
18
+ startDate: string;
19
+ }
20
+
21
+ const employees: Employee[] = [
22
+ { id: '1', name: 'Alice', department: 'Engineering', salary: 120000, startDate: '2020-03-15' },
23
+ { id: '2', name: 'Bob', department: 'Marketing', salary: 85000, startDate: '2021-06-01' },
24
+ { id: '3', name: 'Carol', department: 'Engineering', salary: 140000, startDate: '2019-01-10' },
25
+ { id: '4', name: 'Dave', department: 'Marketing', salary: 92000, startDate: '2022-09-20' },
26
+ { id: '5', name: 'Eve', department: 'Engineering', salary: 110000, startDate: '2023-02-01' },
27
+ ];
28
+
29
+ // --- Basic usage ---
30
+
31
+ const sorting = new Sorting<Employee>();
32
+
33
+ // Subscribe to sort state changes
34
+ sorting.subscribe(() => {
35
+ console.log('Sort changed:', sorting.sorts);
36
+ });
37
+
38
+ // Read current state
39
+ console.log('Key:', sorting.key); // null (no sort active)
40
+ console.log('Direction:', sorting.direction); // 'asc' (default)
41
+
42
+ // Toggle cycles: unsorted → asc → desc → removed
43
+ sorting.toggle('name');
44
+ console.log('After first toggle:', sorting.key, sorting.direction); // 'name', 'asc'
45
+
46
+ sorting.toggle('name');
47
+ console.log('After second toggle:', sorting.key, sorting.direction); // 'name', 'desc'
48
+
49
+ sorting.toggle('name');
50
+ console.log('After third toggle:', sorting.key); // null (removed)
51
+
52
+ // --- Apply sort pipeline ---
53
+
54
+ sorting.setSort('salary', 'desc');
55
+
56
+ const bySalary = sorting.apply(employees);
57
+ console.log('By salary (desc):', bySalary.map(e => `${e.name}: $${e.salary}`));
58
+ // Carol: $140000, Alice: $120000, Eve: $110000, Dave: $92000, Bob: $85000
59
+
60
+ // --- Query methods ---
61
+
62
+ console.log('Is salary sorted:', sorting.isSorted('salary')); // true
63
+ console.log('Salary direction:', sorting.directionOf('salary')); // 'desc'
64
+ console.log('Name direction:', sorting.directionOf('name')); // null (not sorted)
65
+
66
+ // --- Multi-column sort ---
67
+
68
+ // setSorts replaces all descriptors
69
+ sorting.setSorts([
70
+ { key: 'department', direction: 'asc' },
71
+ { key: 'salary', direction: 'desc' },
72
+ ]);
73
+
74
+ const multiSorted = sorting.apply(employees);
75
+ console.log('Multi-sort (dept asc, salary desc):');
76
+ multiSorted.forEach(e => console.log(` ${e.department} — ${e.name}: $${e.salary}`));
77
+ // Engineering — Carol: $140000
78
+ // Engineering — Alice: $120000
79
+ // Engineering — Eve: $110000
80
+ // Marketing — Dave: $92000
81
+ // Marketing — Bob: $85000
82
+
83
+ console.log('Salary index in sort list:', sorting.indexOf('salary')); // 1
84
+
85
+ // --- Custom comparator ---
86
+
87
+ // Custom compareFn for special types (dates, locale-specific strings, etc.)
88
+ sorting.setSort('startDate', 'asc');
89
+
90
+ const byDate = sorting.apply(employees, (a, b, key, dir) => {
91
+ const aVal = a[key as keyof Employee];
92
+ const bVal = b[key as keyof Employee];
93
+ // Date strings sort correctly with localeCompare, but you could parse to Date
94
+ const cmp = String(aVal).localeCompare(String(bVal));
95
+ return dir === 'asc' ? cmp : -cmp;
96
+ });
97
+ console.log('By start date:', byDate.map(e => `${e.name} (${e.startDate})`));
98
+
99
+ // --- Initial sorts via constructor ---
100
+
101
+ const preConfigured = new Sorting<Employee>({
102
+ sorts: [{ key: 'name', direction: 'asc' }],
103
+ });
104
+ console.log('Pre-configured key:', preConfigured.key); // 'name'
105
+
106
+ const sorted = preConfigured.apply(employees);
107
+ console.log('Pre-sorted:', sorted.map(e => e.name)); // Alice, Bob, Carol, Dave, Eve
108
+
109
+ // --- Reset ---
110
+
111
+ sorting.reset();
112
+ console.log('After reset key:', sorting.key); // null
@@ -0,0 +1,58 @@
1
+ import { ViewModel } from 'mvc-kit';
2
+
3
+ // ViewModel: addCleanup pattern for resource management
4
+ //
5
+ // addCleanup() registers teardown callbacks that fire on dispose().
6
+ // Use it for intervals, event listeners, or any resource that needs
7
+ // explicit cleanup. React hooks (useLocal, useSingleton) auto-call
8
+ // dispose() on unmount, so cleanups run automatically.
9
+
10
+ interface TimerState {
11
+ elapsed: number;
12
+ running: boolean;
13
+ }
14
+
15
+ class TimerViewModel extends ViewModel<TimerState> {
16
+ private intervalId?: ReturnType<typeof setInterval>;
17
+
18
+ start() {
19
+ if (this.state.running) return;
20
+
21
+ this.set({ running: true });
22
+ this.intervalId = setInterval(() => {
23
+ this.set(prev => ({ elapsed: prev.elapsed + 1 }));
24
+ }, 1000);
25
+
26
+ // addCleanup registers a teardown callback that fires on dispose()
27
+ // Safe to call multiple times — clearInterval on an already-cleared ID is a no-op
28
+ this.addCleanup(() => clearInterval(this.intervalId));
29
+ }
30
+
31
+ stop() {
32
+ if (!this.state.running) return;
33
+
34
+ clearInterval(this.intervalId);
35
+ this.intervalId = undefined;
36
+ this.set({ running: false });
37
+ }
38
+
39
+ reset() {
40
+ this.stop();
41
+ this.set({ elapsed: 0 });
42
+ }
43
+ }
44
+
45
+ // Usage
46
+ const timer = new TimerViewModel({ elapsed: 0, running: false });
47
+
48
+ timer.subscribe((state) => {
49
+ console.log(`Timer: ${state.elapsed}s (${state.running ? 'running' : 'stopped'})`);
50
+ });
51
+
52
+ timer.start();
53
+
54
+ // Stop after 5 seconds
55
+ setTimeout(() => {
56
+ timer.stop();
57
+ timer.dispose();
58
+ }, 5000);
@@ -0,0 +1,225 @@
1
+ import { Trackable, ViewModel, bindPublicMethods } from 'mvc-kit';
2
+
3
+ // Trackable: Base class for custom reactive objects
4
+ //
5
+ // Provides the subscribable + disposable + auto-bind foundation that
6
+ // all composable helpers (Sorting, Selection, Feed, Pagination, Pending)
7
+ // are built on. Extend it to build your own reactive objects that
8
+ // integrate with ViewModel's auto-tracking system.
9
+ //
10
+ // What Trackable provides:
11
+ // - subscribe() / notify() for change notifications
12
+ // - dispose() / disposeSignal / addCleanup() for lifecycle
13
+ // - Auto-bound public methods (point-free callbacks)
14
+ //
15
+ // What Trackable does NOT provide:
16
+ // - State management (no set(), no state getter)
17
+ // - Computed getters / memoization
18
+ // - Async tracking (no vm.async)
19
+ // - init() lifecycle (implement Initializable yourself if needed)
20
+
21
+ // --- Example 1: Custom RPC query wrapper ---
22
+
23
+ interface RPCResponse<T> {
24
+ data: T;
25
+ success: boolean;
26
+ code: number;
27
+ }
28
+
29
+ class RPCQuery<Args, Data> extends Trackable {
30
+ private _data: Data | undefined = undefined;
31
+ private _loading = false;
32
+ private _error: string | null = null;
33
+ private _callCounter = 0;
34
+
35
+ // Computed getters — auto-tracked when read inside ViewModel getters
36
+ get data(): Data | undefined { return this._data; }
37
+ get loading(): boolean { return this._loading; }
38
+ get error(): string | null { return this._error; }
39
+
40
+ constructor(private _endpoint: string) {
41
+ super();
42
+ }
43
+
44
+ async call(args?: Args): Promise<RPCResponse<Data>> {
45
+ if (this.disposed) throw new Error('RPCQuery: call() after dispose');
46
+
47
+ const callId = ++this._callCounter;
48
+ this._loading = true;
49
+ this._error = null;
50
+ this.notify();
51
+
52
+ try {
53
+ // Simulate an RPC call
54
+ const response = await simulateRPC<Data>(this._endpoint, args);
55
+
56
+ // Stale-call guard: only apply if this is still the latest call
57
+ if (!this.disposed && callId === this._callCounter) {
58
+ this._data = response.data;
59
+ this._loading = false;
60
+ this._error = null;
61
+ this.notify();
62
+ }
63
+
64
+ return response;
65
+ } catch (err) {
66
+ if (!this.disposed && callId === this._callCounter) {
67
+ this._loading = false;
68
+ this._error = (err as Error).message;
69
+ this.notify();
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ clear() {
76
+ this._data = undefined;
77
+ this._error = null;
78
+ this.notify();
79
+ }
80
+ }
81
+
82
+ // Simulate RPC
83
+ async function simulateRPC<T>(_endpoint: string, _args?: unknown): Promise<RPCResponse<T>> {
84
+ await new Promise(r => setTimeout(r, 100));
85
+ return { data: [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }] as T, success: true, code: 200 };
86
+ }
87
+
88
+ // --- Basic usage ---
89
+
90
+ const query = new RPCQuery<{ search: string }, { id: string; name: string }[]>('Users.Search');
91
+
92
+ // Subscribe to changes
93
+ query.subscribe(() => {
94
+ console.log(`Query state: loading=${query.loading}, data=${query.data?.length ?? 0} items, error=${query.error}`);
95
+ });
96
+
97
+ console.log('Loading:', query.loading); // false
98
+ console.log('Data:', query.data); // undefined
99
+
100
+ await query.call({ search: 'alice' });
101
+ // Query state: loading=true, data=0 items, error=null
102
+ // Query state: loading=false, data=2 items, error=null
103
+
104
+ console.log('Data after call:', query.data); // [{ id: '1', name: 'Alice' }, ...]
105
+
106
+ // --- Method binding (point-free) ---
107
+
108
+ const { call, clear, dispose } = query;
109
+ // These work without wrapper functions — auto-bound by Trackable
110
+ clear(); // Resets data
111
+
112
+ // --- Dispose lifecycle ---
113
+
114
+ console.log('Disposed:', query.disposed); // false
115
+ console.log('Signal aborted:', query.disposeSignal.aborted); // false
116
+
117
+ dispose();
118
+
119
+ console.log('Disposed:', query.disposed); // true
120
+ console.log('Signal aborted:', query.disposeSignal.aborted); // true
121
+
122
+ // --- Example 2: ViewModel auto-tracking integration ---
123
+
124
+ interface User {
125
+ id: string;
126
+ name: string;
127
+ }
128
+
129
+ class UsersViewModel extends ViewModel {
130
+ // RPCQuery as a property — auto-tracked by ViewModel's dependency system
131
+ readonly users = new RPCQuery<void, User[]>('Users.List');
132
+
133
+ // This getter auto-invalidates when users.notify() fires
134
+ get userCount(): number {
135
+ return this.users.data?.length ?? 0;
136
+ }
137
+
138
+ get isLoading(): boolean {
139
+ return this.users.loading;
140
+ }
141
+
142
+ get userNames(): string[] {
143
+ return (this.users.data ?? []).map(u => u.name);
144
+ }
145
+
146
+ async onInit() {
147
+ await this.users.call();
148
+ }
149
+ }
150
+
151
+ const vm = new UsersViewModel();
152
+ vm.init();
153
+
154
+ // After init, the RPCQuery fires and getters update automatically
155
+ await new Promise(r => setTimeout(r, 200));
156
+
157
+ console.log('User count:', vm.userCount); // 2
158
+ console.log('User names:', vm.userNames); // ['Alice', 'Bob']
159
+
160
+ vm.dispose();
161
+
162
+ // --- Example 3: addCleanup for external subscriptions ---
163
+
164
+ class LivePrice extends Trackable {
165
+ private _price = 0;
166
+
167
+ get price(): number { return this._price; }
168
+
169
+ constructor(symbol: string) {
170
+ super();
171
+
172
+ // Simulate a WebSocket price subscription
173
+ const interval = setInterval(() => {
174
+ this._price = Math.round(Math.random() * 10000) / 100;
175
+ this.notify();
176
+ }, 1000);
177
+
178
+ // addCleanup runs on dispose — auto-cleanup
179
+ this.addCleanup(() => {
180
+ clearInterval(interval);
181
+ console.log(`Unsubscribed from ${symbol} price feed`);
182
+ });
183
+ }
184
+
185
+ protected onDispose() {
186
+ console.log('LivePrice disposed');
187
+ }
188
+ }
189
+
190
+ const btcPrice = new LivePrice('BTC');
191
+
192
+ const unsub = btcPrice.subscribe(() => {
193
+ console.log(`BTC: $${btcPrice.price}`);
194
+ });
195
+
196
+ // Let it tick a couple times
197
+ await new Promise(r => setTimeout(r, 2500));
198
+
199
+ btcPrice.dispose(); // Clears interval, logs "Unsubscribed...", logs "LivePrice disposed"
200
+ unsub(); // Safe to call after dispose — no-op
201
+
202
+ // --- Example 4: bindPublicMethods standalone utility ---
203
+ //
204
+ // For classes that don't extend Trackable but still want point-free methods.
205
+
206
+ class Formatter {
207
+ constructor(private locale: string) {
208
+ bindPublicMethods(this);
209
+ }
210
+
211
+ formatCurrency(amount: number): string {
212
+ return new Intl.NumberFormat(this.locale, { style: 'currency', currency: 'USD' }).format(amount);
213
+ }
214
+
215
+ formatDate(date: Date): string {
216
+ return new Intl.DateTimeFormat(this.locale).format(date);
217
+ }
218
+ }
219
+
220
+ const fmt = new Formatter('en-US');
221
+
222
+ // Destructured methods work — bound by bindPublicMethods
223
+ const { formatCurrency, formatDate } = fmt;
224
+ console.log(formatCurrency(1234.56)); // $1,234.56
225
+ console.log(formatDate(new Date(2025, 0, 1))); // 1/1/2025
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "allowImportingTsExtensions": true,
10
+ "verbatimModuleSyntax": true,
11
+ "erasableSyntaxOnly": false,
12
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
13
+ "types": [],
14
+ "baseUrl": ".",
15
+ "paths": {
16
+ "mvc-kit": ["../../src/index.ts"]
17
+ }
18
+ },
19
+ "include": ["."]
20
+ }