Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/infra/service/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ interface IApp {

/**
* Notification service
* Shows ephemeral notifications at the bottom right of the screen
* All notifications auto-dismiss after 15 seconds by default
* Toasts bottom-right; same events are listed in the dashboard notification panel (history).
*/
notifications: INotificationService;

Expand Down
8 changes: 7 additions & 1 deletion src/infra/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,17 @@ export {
type LogEntry,
} from "./logger";

// Notification service exports
// Notification service exports (panel UI lives under theme/components/notifications)
export {
notificationService,
NotificationProvider,
useNotificationStore,
type INotificationService,
type NotificationOptions,
type NotificationType,
} from "./notification";
export {
NotificationPanelMenu,
useNotificationPanelStore,
type NotificationPanelEntry,
} from "@/infra/theme/components/notifications";
18 changes: 16 additions & 2 deletions src/infra/service/notification/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { useNotificationStore } from "./notification.store";
import { useNotificationPanelStore } from "@/infra/theme/components/notifications/panel/notification-panel.store";
import type {
INotificationService,
NotificationOptions,
Expand Down Expand Up @@ -50,10 +51,21 @@ class NotificationService implements INotificationService {
// Add notification to store
useNotificationStore.getState().addNotification(notification);

// Auto-remove notification after duration if autoClose is true
if (type !== "loading") {
useNotificationPanelStore.getState().addEntry({
id,
type,
title,
message,
at: Date.now(),
});
}

// Auto-remove toast from overlay list after duration if autoClose is true
// (panel history keeps the entry until the user dismisses it there)
if (autoClose && duration > 0) {
setTimeout(() => {
this.remove(id);
useNotificationStore.getState().removeNotification(id);
}, duration);
}

Expand Down Expand Up @@ -106,10 +118,12 @@ class NotificationService implements INotificationService {

remove(id: string): void {
useNotificationStore.getState().removeNotification(id);
useNotificationPanelStore.getState().removeEntry(id);
}

clear(): void {
useNotificationStore.getState().clearNotifications();
useNotificationPanelStore.getState().clearEntries();
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/infra/theme/components/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./panel";
export * from "./scheduling-hub";
5 changes: 5 additions & 0 deletions src/infra/theme/components/notifications/panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { NotificationPanelMenu } from "./notification-panel-menu";
export {
useNotificationPanelStore,
type NotificationPanelEntry,
} from "./notification-panel.store";
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Bell + menu listing notification history from $app.notifications (via notification-panel store).
*/

import {
ActionIcon,
Indicator,
Menu,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { FaBell } from "react-icons/fa";
import { useNotificationPanelStore } from "./notification-panel.store";

function typeColor(type: string): string | undefined {
switch (type) {
case "success":
return "green";
case "error":
return "red";
case "warning":
return "yellow";
case "info":
return "blue";
default:
return undefined;
}
}
Comment thread
noamarg marked this conversation as resolved.
Outdated

export function NotificationPanelMenu() {
const entries = useNotificationPanelStore((s) => s.entries);
const removeEntry = useNotificationPanelStore((s) => s.removeEntry);
const clearEntries = useNotificationPanelStore((s) => s.clearEntries);

return (
<Menu shadow="md" width={320} position="bottom-end">
<Menu.Target>
<Indicator
inline
disabled={entries.length === 0}
label={entries.length}
size={18}
>
<ActionIcon
variant="subtle"
aria-label="Notifications"
size="lg"
>
<FaBell size={20} />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Notifications</Menu.Label>
{entries.length === 0 ? (
<Text size="sm" c="dimmed" px="sm" py="xs">
No notifications yet
</Text>
) : (
<ScrollArea h={280}>
<Stack gap={4} p="xs">
{entries.map((entry) => (
<div key={entry.id}>
<Text
size="sm"
fw={500}
lineClamp={2}
c={typeColor(entry.type)}
>
{entry.title}
</Text>
{entry.message ? (
<Text size="sm" lineClamp={4}>
{entry.message}
</Text>
) : null}
<Text size="xs" c="dimmed">
{new Date(entry.at).toLocaleString()}
</Text>
<Text
size="xs"
c="blue"
Comment thread
noamarg marked this conversation as resolved.
Outdated
style={{ cursor: "pointer" }}
onClick={() => removeEntry(entry.id)}
>
Dismiss
</Text>
</div>
))}
</Stack>
</ScrollArea>
)}
{entries.length > 0 ? (
<>
<Menu.Divider />
<Menu.Item onClick={clearEntries}>
Clear history
</Menu.Item>
</>
) : null}
</Menu.Dropdown>
</Menu>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { create } from "zustand";
import type { NotificationType } from "@/infra/service/notification/notification.types";

export interface NotificationPanelEntry {
id: string;
title: string;
message?: string;
type: NotificationType;
at: number;
}

interface NotificationPanelStore {
entries: NotificationPanelEntry[];
addEntry: (entry: NotificationPanelEntry) => void;
removeEntry: (id: string) => void;
clearEntries: () => void;
}

const maxEntries = 50;

export const useNotificationPanelStore = create<NotificationPanelStore>(
(set) => ({
entries: [],
addEntry: (entry) =>
set((s) => ({
entries: [entry, ...s.entries].slice(0, maxEntries),
})),
removeEntry: (id) =>
set((s) => ({
entries: s.entries.filter((e) => e.id !== id),
})),
clearEntries: () => set({ entries: [] }),
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as signalR from "@microsoft/signalr";
import { tokenService } from "@/infra/service/ajax/token.service";
import type { SchedulingCompletedPayload } from "./scheduling-hub.types";

function apiBaseUrl(): string {
return (
window.__ENV__?.VITE_API_BASE_URL ||
import.meta.env.VITE_API_BASE_URL ||
"http://localhost:5000/"
);
}
Comment thread
aaron-iz marked this conversation as resolved.
Outdated

export function createSchedulingHubConnection(
onCompleted: (payload: SchedulingCompletedPayload) => void,
): signalR.HubConnection {
const base = apiBaseUrl().replace(/\/$/, "");
const conn = new signalR.HubConnectionBuilder()
.withUrl(`${base}/hubs/scheduling`, {
Comment thread
aaron-iz marked this conversation as resolved.
Outdated
accessTokenFactory: () => tokenService.getToken() ?? "",
})
.withAutomaticReconnect()
.build();

conn.on("SchedulingCompleted", onCompleted);
return conn;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { SchedulingHubConnector } from "./scheduling-hub-connector";
export { useSchedulingHubConnection } from "./use-scheduling-hub-connection";
export { createSchedulingHubConnection } from "./create-scheduling-hub-connection";
export type { SchedulingCompletedPayload } from "./scheduling-hub.types";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useSchedulingHubConnection } from "./use-scheduling-hub-connection";

/**
* Renders nothing; mounts the scheduling SignalR subscription for authenticated layout shells.
*/
export function SchedulingHubConnector() {
useSchedulingHubConnection();
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface SchedulingCompletedPayload {
requestId: string;
success: boolean;
assignmentsCreated: number;
assignmentsModified: number;
unscheduledActivityIds: string[];
failureReason: string | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { $app } from "@/infra/service";
import { createSchedulingHubConnection } from "./create-scheduling-hub-connection";
import type { SchedulingCompletedPayload } from "./scheduling-hub.types";

function schedulingSummary(payload: SchedulingCompletedPayload): string {
if (!payload.success) {
return payload.failureReason ?? "Scheduling failed";
}
let s = `Assignments created: ${payload.assignmentsCreated}, modified: ${payload.assignmentsModified}`;
if (payload.unscheduledActivityIds?.length) {
s += `. Could not schedule ${payload.unscheduledActivityIds.length} activities.`;
}
return s;
}

/**
* Subscribes to scheduling completion events and surfaces them through
* {@link $app.notifications} (toasts + top bar history).
*/
export function useSchedulingHubConnection(): void {
useEffect(() => {
const conn = createSchedulingHubConnection((payload) => {
const summary = schedulingSummary(payload);
if (payload.success) {
$app.notifications.showSuccess("Scheduling complete", summary);
} else {
$app.notifications.showError(
"Scheduling failed",
payload.failureReason ?? summary,
);
}
});

void conn.start().catch((err) => {
console.error("[scheduling hub]", err);
});

return () => {
void conn.stop();
};
}, []);
}
1 change: 1 addition & 0 deletions src/infra/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./mantine-theme";
export * from "./state";
export * from "./components/language-switcher";
export * from "./components/theme-toggle-button";
export * from "./components/notifications";
8 changes: 7 additions & 1 deletion src/infra/theme/layouts/authenticated-page-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router";
import { $app } from "@/infra/service";
import { SchedulingHubConnector } from "@/infra/theme/components/notifications";

const LoginPageRoute = "/";

Expand All @@ -23,5 +24,10 @@ export default function AuthenticatedPageFilter() {
return null;
}

return <Outlet />;
return (
<>
<SchedulingHubConnector />
Comment thread
aaron-iz marked this conversation as resolved.
Outdated
<Outlet />
</>
);
}
2 changes: 2 additions & 0 deletions src/infra/theme/layouts/dashboard-layout/dashboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import styles from "./dashboard-layout.module.css";
import { useDashboardNavigation } from "./use-dashboard-navigation";
import { UserCard } from "@/infra/theme/components/user-card";
import { ThemeToggleButton } from "@/infra/theme/components/theme-toggle-button";
import { NotificationPanelMenu } from "@/infra/theme/components/notifications";
import { useOrganization } from "@/infra/service";
import type { NavigationItem } from "@/infra/federation/module.types";
import { DashboardLoadingScreen } from "./dashboard-loading-screen";
Expand Down Expand Up @@ -202,6 +203,7 @@ export default function DashboardLayout() {
</div>
</Group>
<Group gap="sm">
<NotificationPanelMenu />
<ThemeToggleButton />
<UserCard />
</Group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@
"batchAssignmentConfirmTitle": "Run Batch Assignment",
"batchAssignmentConfirmMessage": "This will delete all current assignments in this semester, then run batch scheduling to create new assignments. This cannot be undone. Continue?",
"batchAssignmentConfirmButton": "Run Batch",
"batchAssignmentSuccess": "Batch assignment has been submitted successfully. Assignments will be created shortly.",
"batchAssignmentSuccess": "Batch scheduling has been queued. You will get a notification when it finishes.",
"batchAssignmentFailed": "Failed to submit batch assignment."
}