2026-06-22 19:03:37 +02:00

223 lines
6.9 KiB
TypeScript

import { getAuthRoleLabel } from '@/business/auth/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay';
import type {
CampusInfo,
UserRole,
} from '@/shared/types/app';
import type { TopBarNotification } from '@/business/top-bar/types';
import type { CommunicationEventDto } from '@/shared/types/communications';
import type { PolicyViewModel } from '@/business/policies/types';
import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types';
import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments';
import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors';
import { ESA_FUNDING_POLICY_TAG } from '@/shared/constants/esaFunding';
export function getTopBarInitials(name: string): string {
return name
.trim()
.split(/\s+/u)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
export function getTopBarCampusLabel(campusInfo?: CampusInfo): string {
return campusInfo?.fullName ?? DEFAULT_CAMPUS_LABEL;
}
export function getTopBarRoleLabel(role: UserRole): string {
return getAuthRoleLabel(role);
}
export function countUnreadTopBarNotifications(
notifications: readonly TopBarNotification[],
): number {
return notifications.filter((notification) => notification.unread).length;
}
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week';
const WEEKLY_SIGN_SELECTION_NOTIFICATION_ID = 'weekly-sign-selection';
const WEEKLY_FRAME_NOTIFICATION_ID = 'weekly-frame-content';
const DAILY_ATTENDANCE_NOTIFICATION_ID = 'daily-attendance-content';
const EI_SELF_ASSESSMENT_NOTIFICATION_ID = 'ei-self-assessment';
const EI_PERSONALITY_QUIZ_NOTIFICATION_ID = 'ei-personality-quiz';
/**
* Builds the top-bar notification list from derived app state. Personal
* completion reminders are driven by backend-backed status queries; there is
* no persisted notifications store yet.
*/
export function buildTopBarNotifications(input: {
readonly needsZoneCheckIn: boolean;
readonly needsSafetyQuiz?: boolean;
readonly needsSignOfWeek?: boolean;
readonly needsWeeklySignSelection?: boolean;
readonly needsWeeklyFrameContent?: boolean;
readonly needsDailyAttendanceContent?: boolean;
readonly needsEiSelfAssessment?: boolean;
readonly needsPersonalityQuiz?: boolean;
readonly communicationEvents?: readonly CommunicationEventDto[];
readonly acknowledgedCommunicationEventIds?: ReadonlySet<string>;
readonly handbookPolicies?: readonly PolicyViewModel[];
readonly safetyProtocols?: readonly SafetyProtocolViewModel[];
readonly policyAcknowledgments?: readonly PolicyAcknowledgmentDto[];
}): readonly TopBarNotification[] {
const notifications: TopBarNotification[] = [];
if (input.needsZoneCheckIn) {
notifications.push({
id: ZONE_CHECKIN_NOTIFICATION_ID,
text: "You haven't logged your Emotional Zone today",
time: 'Today',
unread: true,
href: APP_ROUTE_PATHS.zones,
});
}
if (input.needsSafetyQuiz) {
notifications.push({
id: SAFETY_QUIZ_NOTIFICATION_ID,
text: "You haven't completed this week's QBS safety quiz",
time: 'This week',
unread: true,
href: APP_ROUTE_PATHS.qbs,
});
}
if (input.needsSignOfWeek) {
notifications.push({
id: SIGN_OF_WEEK_NOTIFICATION_ID,
text: "You haven't learned this week's sign",
time: 'This week',
unread: true,
href: APP_ROUTE_PATHS.signs,
});
}
if (input.needsWeeklySignSelection) {
notifications.push({
id: WEEKLY_SIGN_SELECTION_NOTIFICATION_ID,
text: "Select this week's Sign of the Week",
time: 'This week',
unread: true,
href: APP_ROUTE_PATHS.signs,
});
}
if (input.needsWeeklyFrameContent) {
notifications.push({
id: WEEKLY_FRAME_NOTIFICATION_ID,
text: "Publish this week's F.R.A.M.E. entry",
time: 'This week',
unread: true,
href: APP_ROUTE_PATHS.frame,
});
}
if (input.needsDailyAttendanceContent) {
notifications.push({
id: DAILY_ATTENDANCE_NOTIFICATION_ID,
text: "Submit today's attendance",
time: 'Today',
unread: true,
href: APP_ROUTE_PATHS.attendance,
});
}
if (input.needsEiSelfAssessment) {
notifications.push({
id: EI_SELF_ASSESSMENT_NOTIFICATION_ID,
text: "You haven't completed your EI self-assessment",
time: 'This week',
unread: true,
href: APP_ROUTE_PATHS.ei,
});
}
if (input.needsPersonalityQuiz) {
notifications.push({
id: EI_PERSONALITY_QUIZ_NOTIFICATION_ID,
text: "You haven't completed your personality type quiz",
time: 'Once',
unread: true,
href: APP_ROUTE_PATHS.ei,
});
}
for (const event of input.communicationEvents ?? []) {
if (input.acknowledgedCommunicationEventIds?.has(event.id)) {
continue;
}
notifications.push({
id: `internal-alert-${event.id}`,
text: `Internal alert: ${event.title}`,
time: new Date(event.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}),
unread: true,
href: APP_ROUTE_PATHS.internalComm,
});
}
for (const policy of input.handbookPolicies ?? []) {
if (isPolicyDocumentAcknowledged(input.policyAcknowledgments ?? [], policy.id, policy.version)) {
continue;
}
const esaUpdatedSection = policy.tag === ESA_FUNDING_POLICY_TAG
? getEsaUpdatedSection(policy)
: null;
notifications.push({
id: `handbook-policy-${policy.id}-v${policy.version}`,
text: policy.tag === ESA_FUNDING_POLICY_TAG
? `Updated ESA funding section: ${esaUpdatedSection ?? policy.title}`
: `Unread handbook policy: ${policy.title}`,
time: `Version ${policy.version}`,
unread: true,
href: policy.tag === ESA_FUNDING_POLICY_TAG
? APP_ROUTE_PATHS.esa
: APP_ROUTE_PATHS.handbook,
});
}
for (const protocol of input.safetyProtocols ?? []) {
if (isPolicyDocumentAcknowledged(input.policyAcknowledgments ?? [], protocol.id, protocol.version)) {
continue;
}
notifications.push({
id: `safety-protocol-${protocol.id}-v${protocol.version}`,
text: `Unread safety protocol: ${protocol.title}`,
time: `Version ${protocol.version}`,
unread: true,
href: APP_ROUTE_PATHS.safety,
});
}
return notifications;
}
function getEsaUpdatedSection(policy: PolicyViewModel): string | null {
const marker = 'Updated section:';
const line = policy.content
.split('\n')
.map((item) => item.trim())
.find((item) => item.startsWith(marker));
if (!line) {
return null;
}
const sectionName = line.slice(marker.length).trim();
return sectionName.length > 0 ? sectionName : null;
}