All files / src/components/organizer/EventPage tabBadges.ts

95.83% Statements 23/24
83.33% Branches 20/24
100% Functions 6/6
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96                                                                                    1x                 1x       16x 4x 16x 16x   3x 16x       3x 2x 2x 2x                     16x   16x 16x   16x 4x     16x              
/**
 * Count-driven tab attention badges (Epic 14, Story 14.A.3 / FR6).
 *
 * Pure, framework-free derivation so it can be unit-tested in isolation. All
 * inputs come from EXISTING client data (event sessions + metrics, the task
 * list, the publishing-status endpoint) — no backend change (NFR9).
 */
 
import type { EventTaskResponse } from '@/services/taskService';
import type { PublishingStatusResponse, PublishingPhase } from '@/types/event.types';
 
export interface TabBadges {
  /** Speakers & Agenda: sessions needing a slot + speakers needing a content review. 0 = no badge. */
  speakers: number;
  /** Publishing: the next unpublished phase passes validation and is ready to publish. */
  publishingReady: boolean;
  /** Communications: at least one comms task is overdue (renders as a red dot). */
  commsOverdue: boolean;
  /**
   * Registrations: active registration total (confirmed + waitlist). Rendered as a
   * SUBTLE MUTED count on the tab label, not a heavy attention badge (Epic 14 FR24).
   * 0 = no count shown.
   */
  registrations: number;
}
 
interface SessionLike {
  startTime?: string | null;
}
 
export interface ComputeTabBadgesInput {
  sessions?: SessionLike[];
  /** Speakers with pending materials (a content review proxy, from metrics expansion). */
  pendingMaterialsCount?: number;
  tasks?: EventTaskResponse[];
  publishingStatus?: PublishingStatusResponse;
  /** Active registration total (confirmed + waitlist) for the muted Registrations count. */
  registrationsCount?: number;
  /** Epoch ms used for overdue comparison (injected for testability). */
  now: number;
}
 
const PHASE_ORDER: PublishingPhase[] = ['topic', 'speakers', 'agenda'];
 
/**
 * Heuristic match for clearly communications-owned tasks (DE + EN). Kept
 * deliberately narrow — newsletter / registrant-notice surfaces only — to avoid
 * false positives from generic tasks (e.g. "Book the venue", "Task reminder").
 * Interim: Phase E replaces this with a first-class audience model, at which
 * point venue/caterer-coordination tasks can also feed the dot. (See deferred-work.)
 */
const COMMS_TASK_PATTERN = /newsletter|registrant|teilnehmer-?info|kommunikat|notice/i;
 
/** Is the next unpublished publishing phase valid (i.e. ready to publish)? */
function isPublishingReady(status: PublishingStatusResponse | undefined): boolean {
  if (!status) return false;
  const published = status.publishedPhases ?? [];
  const next = PHASE_ORDER.find((p) => !published.includes(p));
  if (!next) return false; // everything already published
  const validation =
    next === 'topic' ? status.topic : next === 'speakers' ? status.speakers : status.sessions;
  return !!validation?.isValid;
}
 
function isOverdue(task: EventTaskResponse, now: number): boolean {
  if (task.status === 'completed') return false;
  Iif (!task.dueDate) return false;
  const due = new Date(task.dueDate).getTime();
  return !Number.isNaN(due) && due < now;
}
 
export function computeTabBadges(input: ComputeTabBadgesInput): TabBadges {
  const {
    sessions = [],
    pendingMaterialsCount = 0,
    tasks = [],
    publishingStatus,
    registrationsCount = 0,
    now,
  } = input;
 
  const sessionsNeedingSlot = sessions.filter((s) => !s.startTime).length;
  const speakers = sessionsNeedingSlot + Math.max(0, pendingMaterialsCount);
 
  const commsOverdue = tasks.some(
    (task) => COMMS_TASK_PATTERN.test(task.taskName ?? '') && isOverdue(task, now)
  );
 
  return {
    speakers,
    publishingReady: isPublishingReady(publishingStatus),
    commsOverdue,
    registrations: Math.max(0, registrationsCount),
  };
}