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.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +4 -2
|
@@ -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.
|
|
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"
|