mvc-kit 2.13.0 → 2.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
- package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
- package/agent-config/lib/install-claude.mjs +39 -110
- 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
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import { ActivityFeedViewModel } from '../../viewmodels/ActivityFeedViewModel';
|
|
3
|
+
import { ActivityItemRow } from './ActivityItemRow';
|
|
4
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
5
|
+
import { Spinner } from '../shared/Spinner';
|
|
6
|
+
|
|
7
|
+
export function ActivityFeed() {
|
|
8
|
+
const [, vm] = useLocal(ActivityFeedViewModel);
|
|
9
|
+
const { loading, error } = vm.async.load;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="activity-feed">
|
|
13
|
+
<div className="section-header">
|
|
14
|
+
<h2>Activity Feed</h2>
|
|
15
|
+
<StatusIndicator connected={vm.isConnected} label={vm.isOnline ? 'Live' : 'Offline'} />
|
|
16
|
+
<span className="item-count">{vm.itemCount} items</span>
|
|
17
|
+
</div>
|
|
18
|
+
{loading && vm.itemCount === 0 && (
|
|
19
|
+
<div className="loading-center"><Spinner /></div>
|
|
20
|
+
)}
|
|
21
|
+
{error && (
|
|
22
|
+
<div className="error-banner">{error}</div>
|
|
23
|
+
)}
|
|
24
|
+
<div className="activity-list">
|
|
25
|
+
{vm.items.map(item => (
|
|
26
|
+
<ActivityItemRow key={item.id} item={item} />
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ActivityItem } from '../../types/activity';
|
|
2
|
+
|
|
3
|
+
interface ActivityItemRowProps {
|
|
4
|
+
item: ActivityItem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ActivityItemRow({ item }: ActivityItemRowProps) {
|
|
8
|
+
const timeAgo = getTimeAgo(item.timestamp);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="activity-row">
|
|
12
|
+
<img
|
|
13
|
+
className={`activity-avatar ${item.userOnline ? '' : 'avatar-offline'}`}
|
|
14
|
+
src={item.userAvatar}
|
|
15
|
+
alt={item.userName}
|
|
16
|
+
width={36}
|
|
17
|
+
height={36}
|
|
18
|
+
/>
|
|
19
|
+
<div className="activity-content">
|
|
20
|
+
<span className="activity-text">{item.text}</span>
|
|
21
|
+
<span className="activity-time">{timeAgo}</span>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTimeAgo(timestamp: string): string {
|
|
28
|
+
const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
|
|
29
|
+
if (seconds < 5) return 'just now';
|
|
30
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
31
|
+
const minutes = Math.floor(seconds / 60);
|
|
32
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
33
|
+
const hours = Math.floor(minutes / 60);
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import type { DashboardCardViewModel } from '../../viewmodels/DashboardCardViewModel';
|
|
3
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
4
|
+
|
|
5
|
+
interface DashboardCardProps {
|
|
6
|
+
VMClass: new () => DashboardCardViewModel;
|
|
7
|
+
title: string;
|
|
8
|
+
icon: string;
|
|
9
|
+
unit?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function DashboardCard({ VMClass, title, icon, unit }: DashboardCardProps) {
|
|
13
|
+
const [, vm] = useLocal(VMClass);
|
|
14
|
+
|
|
15
|
+
const trendArrow = vm.trend === 'up' ? '\u2191' : vm.trend === 'down' ? '\u2193' : '\u2192';
|
|
16
|
+
const trendClass = `trend-${vm.trend}`;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={`card ${vm.isOnline ? '' : 'card-disconnected'}`}>
|
|
20
|
+
<div className="card-header">
|
|
21
|
+
<span className="card-icon">{icon}</span>
|
|
22
|
+
<span className="card-title">{title}</span>
|
|
23
|
+
<StatusIndicator connected={vm.isConnected} />
|
|
24
|
+
</div>
|
|
25
|
+
<div className="card-value">
|
|
26
|
+
<span className="card-number">
|
|
27
|
+
{vm.latestValue.toLocaleString()}
|
|
28
|
+
</span>
|
|
29
|
+
{unit && <span className="card-unit">{unit}</span>}
|
|
30
|
+
<span className={`card-trend ${trendClass}`}>{trendArrow}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="card-footer">
|
|
33
|
+
{vm.dataPointCount} data points
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DashboardCard } from './DashboardCard';
|
|
2
|
+
import type { DashboardCardViewModel } from '../../viewmodels/DashboardCardViewModel';
|
|
3
|
+
import { OrdersCardViewModel } from '../../viewmodels/OrdersCardViewModel';
|
|
4
|
+
import { RevenueCardViewModel } from '../../viewmodels/RevenueCardViewModel';
|
|
5
|
+
import { UsersMetricCardViewModel } from '../../viewmodels/UsersMetricCardViewModel';
|
|
6
|
+
import { ErrorsCardViewModel } from '../../viewmodels/ErrorsCardViewModel';
|
|
7
|
+
import { LatencyCardViewModel } from '../../viewmodels/LatencyCardViewModel';
|
|
8
|
+
import { TrafficCardViewModel } from '../../viewmodels/TrafficCardViewModel';
|
|
9
|
+
|
|
10
|
+
interface CardConfig {
|
|
11
|
+
VMClass: new () => DashboardCardViewModel;
|
|
12
|
+
title: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
unit?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cards: CardConfig[] = [
|
|
18
|
+
{ VMClass: OrdersCardViewModel, title: 'Orders', icon: '\uD83D\uDCE6', unit: '/hr' },
|
|
19
|
+
{ VMClass: RevenueCardViewModel, title: 'Revenue', icon: '\uD83D\uDCB0', unit: '$' },
|
|
20
|
+
{ VMClass: UsersMetricCardViewModel, title: 'Active Users', icon: '\uD83D\uDC65' },
|
|
21
|
+
{ VMClass: ErrorsCardViewModel, title: 'Errors', icon: '\u26A0\uFE0F', unit: '/min' },
|
|
22
|
+
{ VMClass: LatencyCardViewModel, title: 'Latency', icon: '\u23F1\uFE0F', unit: 'ms' },
|
|
23
|
+
{ VMClass: TrafficCardViewModel, title: 'Traffic', icon: '\uD83C\uDF10', unit: 'req/s' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function DashboardPage() {
|
|
27
|
+
return (
|
|
28
|
+
<div className="dashboard-grid">
|
|
29
|
+
{cards.map(({ VMClass, title, icon, unit }) => (
|
|
30
|
+
<DashboardCard key={title} VMClass={VMClass} title={title} icon={icon} unit={unit} />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { AppStateViewModel } from '../../viewmodels/AppStateViewModel';
|
|
3
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
4
|
+
import { SocialFeedPanel } from './SocialFeedPanel';
|
|
5
|
+
|
|
6
|
+
export function Navbar() {
|
|
7
|
+
const [state, vm] = useSingleton(AppStateViewModel);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<nav className="navbar">
|
|
12
|
+
<span className="navbar-brand">ComplexApp</span>
|
|
13
|
+
<div className="navbar-actions">
|
|
14
|
+
<StatusIndicator connected={state.online} label={state.online ? 'Online' : 'Offline'} />
|
|
15
|
+
<button
|
|
16
|
+
className={`btn ${state.online ? 'btn-danger' : 'btn-success'}`}
|
|
17
|
+
onClick={() => vm.toggleOnline()}
|
|
18
|
+
>
|
|
19
|
+
{state.online ? 'Go Offline' : 'Go Online'}
|
|
20
|
+
</button>
|
|
21
|
+
<button
|
|
22
|
+
className={`btn btn-secondary ${state.socialPanelOpen ? 'btn-active' : ''}`}
|
|
23
|
+
onClick={() => vm.toggleSocialPanel()}
|
|
24
|
+
>
|
|
25
|
+
Social Feed
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
{state.socialPanelOpen && <SocialFeedPanel />}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import { InfiniteScroll, CardList } from 'mvc-kit/react';
|
|
3
|
+
import { SocialFeedViewModel } from '../../viewmodels/SocialFeedViewModel';
|
|
4
|
+
import { Spinner } from '../shared/Spinner';
|
|
5
|
+
import type { SocialPost } from '../../types/social';
|
|
6
|
+
|
|
7
|
+
function PostCard({ post }: { post: SocialPost }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="social-post">
|
|
10
|
+
<div className="social-post-header">
|
|
11
|
+
<img
|
|
12
|
+
className="social-avatar"
|
|
13
|
+
src={post.avatarUrl}
|
|
14
|
+
alt={post.author}
|
|
15
|
+
width={32}
|
|
16
|
+
height={32}
|
|
17
|
+
/>
|
|
18
|
+
<span className="social-author">{post.author}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<p className="social-content">{post.content}</p>
|
|
21
|
+
<div className="social-footer">
|
|
22
|
+
<span className="social-likes">{'\u2764\uFE0F'} {post.likes}</span>
|
|
23
|
+
<span className="social-time">
|
|
24
|
+
{new Date(post.timestamp).toLocaleTimeString()}
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SocialFeedPanel() {
|
|
32
|
+
const [state, vm] = useLocal(SocialFeedViewModel, { page: 0 });
|
|
33
|
+
const { loading } = vm.async.loadMore;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="social-feed-panel">
|
|
37
|
+
<h3 className="social-feed-title">Social Feed</h3>
|
|
38
|
+
<InfiniteScroll
|
|
39
|
+
hasMore={vm.hasMore}
|
|
40
|
+
loading={loading}
|
|
41
|
+
onLoadMore={() => vm.loadMore()}
|
|
42
|
+
renderLoading={() => (
|
|
43
|
+
<div className="loading-center"><Spinner /></div>
|
|
44
|
+
)}
|
|
45
|
+
renderEnd={() =>
|
|
46
|
+
vm.postCount > 0 ? <p className="end-of-feed">No more posts</p> : null
|
|
47
|
+
}
|
|
48
|
+
>
|
|
49
|
+
<CardList
|
|
50
|
+
items={vm.posts}
|
|
51
|
+
keyOf={(post: SocialPost) => post.id}
|
|
52
|
+
renderItem={(post: SocialPost) => <PostCard post={post} />}
|
|
53
|
+
/>
|
|
54
|
+
</InfiniteScroll>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface StatusIndicatorProps {
|
|
2
|
+
connected: boolean;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function StatusIndicator({ connected, label }: StatusIndicatorProps) {
|
|
7
|
+
return (
|
|
8
|
+
<span className="status-indicator">
|
|
9
|
+
<span className={`status-dot ${connected ? 'status-dot-online' : 'status-dot-offline'}`} />
|
|
10
|
+
{label && <span className="status-label">{label}</span>}
|
|
11
|
+
</span>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { useSingleton, useEvent } from 'mvc-kit/react';
|
|
3
|
+
import { AppEventBus } from '../../events/AppEventBus';
|
|
4
|
+
|
|
5
|
+
interface ToastItem {
|
|
6
|
+
id: number;
|
|
7
|
+
message: string;
|
|
8
|
+
type: 'info' | 'success' | 'error';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let nextId = 0;
|
|
12
|
+
|
|
13
|
+
export function Toast() {
|
|
14
|
+
const bus = useSingleton(AppEventBus);
|
|
15
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
16
|
+
|
|
17
|
+
const addToast = useCallback((item: Omit<ToastItem, 'id'>) => {
|
|
18
|
+
const id = nextId++;
|
|
19
|
+
setToasts(prev => [...prev, { ...item, id }]);
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
22
|
+
}, 3000);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
useEvent(bus, 'toast:show', ({ message, type }) => {
|
|
26
|
+
addToast({ message, type: type ?? 'info' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (toasts.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="toast-container">
|
|
33
|
+
{toasts.map(t => (
|
|
34
|
+
<div key={t.id} className={`toast toast-${t.type}`}>
|
|
35
|
+
{t.message}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const __MVC_KIT_DEV__: boolean;
|
|
2
|
+
|
|
3
|
+
declare module 'react-dom/client' {
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
interface Root {
|
|
6
|
+
render(children: ReactNode): void;
|
|
7
|
+
unmount(): void;
|
|
8
|
+
}
|
|
9
|
+
export function createRoot(container: Element): Root;
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface MockWSConfig {
|
|
2
|
+
minInterval: number;
|
|
3
|
+
maxInterval: number;
|
|
4
|
+
generator: () => any;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MockWebSocket {
|
|
8
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
9
|
+
private closed = false;
|
|
10
|
+
|
|
11
|
+
constructor(private config: MockWSConfig) {}
|
|
12
|
+
|
|
13
|
+
connect(onMessage: (data: any) => void, signal?: AbortSignal): void {
|
|
14
|
+
if (signal?.aborted) return;
|
|
15
|
+
signal?.addEventListener('abort', () => this.close(), { once: true });
|
|
16
|
+
this.scheduleNext(onMessage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close(): void {
|
|
20
|
+
if (this.closed) return;
|
|
21
|
+
this.closed = true;
|
|
22
|
+
if (this.timer !== null) {
|
|
23
|
+
clearTimeout(this.timer);
|
|
24
|
+
this.timer = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private scheduleNext(onMessage: (data: any) => void): void {
|
|
29
|
+
if (this.closed) return;
|
|
30
|
+
const { minInterval, maxInterval, generator } = this.config;
|
|
31
|
+
const interval = minInterval + Math.random() * (maxInterval - minInterval);
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
if (this.closed) return;
|
|
34
|
+
onMessage(generator());
|
|
35
|
+
this.scheduleNext(onMessage);
|
|
36
|
+
}, interval);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ActivityItem } from '../types/activity';
|
|
2
|
+
import type { MockWSConfig } from './MockWebSocket';
|
|
3
|
+
import { mockFetch } from './delay';
|
|
4
|
+
|
|
5
|
+
const NAMES = [
|
|
6
|
+
'Alice Johnson', 'Bob Smith', 'Carol White', 'Dave Brown', 'Eve Davis',
|
|
7
|
+
'Frank Wilson', 'Grace Lee', 'Hank Taylor', 'Ivy Martin', 'Jack Anderson',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const ACTIONS = [
|
|
11
|
+
'created a new order', 'updated their profile', 'submitted a report',
|
|
12
|
+
'closed a ticket', 'uploaded a document', 'left a comment',
|
|
13
|
+
'approved a request', 'joined the team', 'completed a task', 'shared a file',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function randomItem<T>(arr: T[]): T {
|
|
17
|
+
return arr[Math.floor(Math.random() * arr.length)]!;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let activitySeq = 0;
|
|
21
|
+
|
|
22
|
+
function generateActivity(): ActivityItem {
|
|
23
|
+
const name = randomItem(NAMES);
|
|
24
|
+
return {
|
|
25
|
+
id: `act-${++activitySeq}`,
|
|
26
|
+
text: `${name} ${randomItem(ACTIONS)}`,
|
|
27
|
+
userName: name,
|
|
28
|
+
userAvatar: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`,
|
|
29
|
+
userOnline: Math.random() > 0.3,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchInitialActivity(signal?: AbortSignal): Promise<ActivityItem[]> {
|
|
35
|
+
const items: ActivityItem[] = [];
|
|
36
|
+
for (let i = 0; i < 50; i++) {
|
|
37
|
+
items.push(generateActivity());
|
|
38
|
+
}
|
|
39
|
+
return mockFetch(items, 600, signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getActivityWSConfig(): MockWSConfig {
|
|
43
|
+
return {
|
|
44
|
+
minInterval: 1000,
|
|
45
|
+
maxInterval: 3000,
|
|
46
|
+
generator: generateActivity,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MockWSConfig } from './MockWebSocket';
|
|
2
|
+
|
|
3
|
+
interface GeneratorConfig {
|
|
4
|
+
baseline: number;
|
|
5
|
+
variance: number;
|
|
6
|
+
min?: number;
|
|
7
|
+
minInterval?: number;
|
|
8
|
+
maxInterval?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const configs: Record<string, GeneratorConfig> = {
|
|
12
|
+
orders: { baseline: 150, variance: 10 },
|
|
13
|
+
revenue: { baseline: 52000, variance: 500 },
|
|
14
|
+
'active-users': { baseline: 1200, variance: 50 },
|
|
15
|
+
errors: { baseline: 3, variance: 2, min: 0 },
|
|
16
|
+
latency: { baseline: 145, variance: 30, min: 1 },
|
|
17
|
+
traffic: { baseline: 8500, variance: 500 },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let counters: Record<string, number> = {};
|
|
21
|
+
|
|
22
|
+
function randomWalk(serviceId: string, cfg: GeneratorConfig): number {
|
|
23
|
+
if (!(serviceId in counters)) {
|
|
24
|
+
counters[serviceId] = cfg.baseline;
|
|
25
|
+
}
|
|
26
|
+
const delta = (Math.random() - 0.5) * 2 * cfg.variance;
|
|
27
|
+
counters[serviceId] += delta;
|
|
28
|
+
if (cfg.min !== undefined && counters[serviceId] < cfg.min) {
|
|
29
|
+
counters[serviceId] = cfg.min;
|
|
30
|
+
}
|
|
31
|
+
return Math.round(counters[serviceId]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getDashboardConfig(serviceId: string): MockWSConfig {
|
|
35
|
+
const cfg = configs[serviceId] ?? { baseline: 100, variance: 10 };
|
|
36
|
+
return {
|
|
37
|
+
minInterval: cfg.minInterval ?? 400,
|
|
38
|
+
maxInterval: cfg.maxInterval ?? 1500,
|
|
39
|
+
generator: () => ({
|
|
40
|
+
id: `${serviceId}-${Date.now()}`,
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
count: randomWalk(serviceId, cfg),
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
if (signal?.aborted) {
|
|
4
|
+
reject(signal.reason);
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
const timer = setTimeout(resolve, ms);
|
|
8
|
+
signal?.addEventListener('abort', () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
reject(signal.reason);
|
|
11
|
+
}, { once: true });
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function mockFetch<T>(data: T, ms = 300, signal?: AbortSignal): Promise<T> {
|
|
16
|
+
await delay(ms, signal);
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SocialPost } from '../types/social';
|
|
2
|
+
import { mockFetch } from './delay';
|
|
3
|
+
|
|
4
|
+
const AUTHORS = [
|
|
5
|
+
'Sarah Chen', 'Mike Rodriguez', 'Emily Park', 'Chris Taylor', 'Jordan Lee',
|
|
6
|
+
'Sam Williams', 'Alex Murphy', 'Kim Nguyen', 'Pat O\'Brien', 'Robin Cruz',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const CONTENTS = [
|
|
10
|
+
'Just shipped a new feature! The team crushed it this sprint.',
|
|
11
|
+
'Anyone else excited about the new TypeScript release?',
|
|
12
|
+
'Great architecture review today. Clean patterns make all the difference.',
|
|
13
|
+
'Coffee and code — name a better duo.',
|
|
14
|
+
'TIL: Object.freeze is shallow. Always has been.',
|
|
15
|
+
'Pair programming session was incredibly productive today.',
|
|
16
|
+
'Hot take: tests are documentation.',
|
|
17
|
+
'Finally figured out that race condition. AbortController saves the day!',
|
|
18
|
+
'Reading through the codebase and loving the consistency.',
|
|
19
|
+
'Deployed to production with zero downtime. Feels good.',
|
|
20
|
+
'State management doesn\'t have to be complicated.',
|
|
21
|
+
'Code review feedback is a gift, not a critique.',
|
|
22
|
+
'The best code is the code you don\'t have to write.',
|
|
23
|
+
'Refactoring legacy code is oddly satisfying.',
|
|
24
|
+
'Remember: premature optimization is the root of all evil.',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
let postSeq = 0;
|
|
28
|
+
const TOTAL_POSTS = 120;
|
|
29
|
+
|
|
30
|
+
export async function fetchSocialFeed(
|
|
31
|
+
page: number,
|
|
32
|
+
pageSize: number,
|
|
33
|
+
signal?: AbortSignal,
|
|
34
|
+
): Promise<{ items: SocialPost[]; hasMore: boolean }> {
|
|
35
|
+
const start = page * pageSize;
|
|
36
|
+
if (start >= TOTAL_POSTS) {
|
|
37
|
+
return mockFetch({ items: [], hasMore: false }, 200, signal);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const count = Math.min(pageSize, TOTAL_POSTS - start);
|
|
41
|
+
const items: SocialPost[] = [];
|
|
42
|
+
for (let i = 0; i < count; i++) {
|
|
43
|
+
const author = AUTHORS[Math.floor(Math.random() * AUTHORS.length)]!;
|
|
44
|
+
items.push({
|
|
45
|
+
id: `post-${++postSeq}`,
|
|
46
|
+
content: CONTENTS[Math.floor(Math.random() * CONTENTS.length)]!,
|
|
47
|
+
author,
|
|
48
|
+
avatarUrl: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`,
|
|
49
|
+
timestamp: new Date(Date.now() - Math.random() * 86400000).toISOString(),
|
|
50
|
+
likes: Math.floor(Math.random() * 50),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return mockFetch({ items, hasMore: start + count < TOTAL_POSTS }, 500, signal);
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Resource } from 'mvc-kit';
|
|
2
|
+
import type { ActivityItem } from '../types/activity';
|
|
3
|
+
import { fetchInitialActivity } from '../mock-remote/activity-api';
|
|
4
|
+
|
|
5
|
+
export class ActivityResource extends Resource<ActivityItem> {
|
|
6
|
+
static override MAX_SIZE = 100;
|
|
7
|
+
|
|
8
|
+
async loadInitial(): Promise<void> {
|
|
9
|
+
const items = await fetchInitialActivity(this.disposeSignal);
|
|
10
|
+
this.reset(items);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Resource } from 'mvc-kit';
|
|
2
|
+
import type { SocialPost } from '../types/social';
|
|
3
|
+
import { fetchSocialFeed } from '../mock-remote/social-api';
|
|
4
|
+
|
|
5
|
+
export class SocialFeedResource extends Resource<SocialPost> {
|
|
6
|
+
private _hasMore = true;
|
|
7
|
+
|
|
8
|
+
get hasMore(): boolean {
|
|
9
|
+
return this._hasMore;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async loadPage(page: number): Promise<void> {
|
|
13
|
+
const { items, hasMore } = await fetchSocialFeed(page, 20, this.disposeSignal);
|
|
14
|
+
this._hasMore = hasMore;
|
|
15
|
+
this.upsert(...items);
|
|
16
|
+
}
|
|
17
|
+
}
|