Skip to content

Coach Workflow / Daily Triage / Client Ranking

Priority view ranks coach-client relationships so coaches know who needs attention first.

The Priority view is a derived client-level triage surface. It does not replace the coach task inbox or risk flags. It aggregates open tasks, risk state, overdue commitments, client activity freshness, message reply gaps, onboarding blockers, and manual follow-up pins into an explainable ranked list of clients.

Page status

  • Post-V1 coach workflow
  • Client-level ranking
  • Explainable score
  • Inbox integrated

Not in Public V1

The Public V1 scope contract defers the full Priority view as a separate ranked-client product surface to Post-V1. Public V1 covers coach triage with task inbox sorting (coach_tasks.priority_score) and deterministic risk signals instead.

This page stays the approved design for the surface when it is built. Public V1 coach web and coach mobile must not depend on client-level rankScore, PriorityTier, or the explainable reason stack — but they should leave room for it, because Priority view is additive over data V1 already owns. Section terminology below still reads as if this shipped in V1 and needs a pass when the page is scheduled.

Design decision

Treat Priority view as an operational read model, not a separate source of truth. Source contexts own the events and data; coach_tasks owns individual actionable items; client_risk_flags owns risk state; Priority view owns the current ranking, reason summaries, and daily snapshots for the coach's visible relationships. Use deterministic thresholds: any critical signal becomes CRITICAL, scores of 75+ are HIGH, 45-74 are MEDIUM, 1-44 are LOW, and 0 is CLEAR. Critical crossings can push; high crossings update badges and daily digest only.

Priority View Flows

The view is built by projectors and scoring policy, then used by coach web and coach mobile as a start-of-day triage list.

System Builds The Ranking

  1. Source contexts emit operational events such as CoachTaskCreated, ClientRiskFlagRaised, ClientMessageNeedsReply, DailyLogMissed, WorkoutSubmittedForReview, and CoachClientRelationshipPaused.
  2. Priority projectors normalize those events into CoachPrioritySignal records keyed by workspace, coach-client relationship, signal type, and source reference.
  3. The scoring service recalculates one current CoachPriorityItem per visible coach-client relationship.
  4. The item stores score breakdown, top reasons, priority tier, next due timestamp, primary action, and source deep-link target.
  5. Coach web and coach mobile read the ranked list from the projection; they do not calculate ranking in the client.

Coach Starts Daily Review

  1. Coach opens Priority view and sees clients sorted by rankScore, then due time, then latest client activity.
  2. Each row shows client status, tier, top reasons, highest-risk flag, oldest overdue task, unread message status, and a primary action.
  3. Coach filters by priority tier, due today, onboarding, high risk, or unanswered messages.
  4. Opening a row shows the compact priority detail and links to task inbox, risk flag, profile, form review, workout review, nutrition review, or message thread.
  5. When the coach resolves a task, handles a risk flag, messages the client, or updates the plan, source events update the ranking automatically.

Manual Focus Or Pin

  1. Coach can pin a client to the top of their personal Priority view for a short follow-up window.
  2. Manual pins default to 24 hours and offer preset expirations of 24 hours, 3 days, and 7 days. Web can allow a custom expiration capped at 7 days.
  3. A coach can suppress a low-priority client until a later date when the current reason is acknowledged but no action is needed.
  4. Manual pinning can lift display order, but cannot hide critical system risks or overdue client-facing commitments.
  5. Manual changes are stored as priority events and included in the score breakdown as coach-specific modifiers.

Daily Snapshot

  1. Daily snapshots preserve the order the coach saw at the start of the day for support and reporting.
  2. Snapshot payloads store safe reason summaries and source references, not raw message, health, nutrition, or photo content.
  3. Snapshots are append-only for 90 days, then reduced to monthly aggregate counts retained for 12 months.

Domain Model

Priority view belongs to CoachWorkflow as a projection and ranking policy over coach-client relationships.

Aggregates & Entities

  • CoachPriorityItem: current ranked projection for one workspace and coach-client relationship.
  • CoachPrioritySignal: normalized active or expired signal contributing to a relationship score.
  • CoachPriorityReason: explainable reason shown in the UI, derived from one or more signals.
  • CoachPrioritySnapshot: immutable daily ranking snapshot for reporting, digest generation, and support review.
  • CoachPriorityViewPreference: per-coach filters, default tier filter, and suppression display behavior.
  • CoachPriorityPolicy: domain service for scoring, tier thresholds, reason selection, suppression rules, and manual pin handling.

Value Objects & Rules

  • PriorityTier: CRITICAL, HIGH, MEDIUM, LOW, CLEAR.
  • PrioritySignalType: OPEN_TASK, RISK_FLAG, O1ERDUE_ACTION, MESSAGE_REPLY_GAP, ONBOARDING_BLOCKER, ACTI1ITY_DROP, PROGRESS_STALL, MANUAL_PIN.
  • PriorityAction: OPEN_TASK, RE1IEW_RISK, MESSAGE_CLIENT, RE1IEW_CHECK_IN, UPDATE_PLAN, RE1IEW_PROFILE, NO_ACTION.
  • ScoreBreakdown: explainable numeric contribution by tasks, risk, due dates, communication, activity, onboarding, and manual modifiers.
  • PriorityTierThresholds: CRITICAL if any active signal severity is critical; HIGH at 75-100; MEDIUM at 45-74; LOW at 1-44; CLEAR at 0.
  • PriorityRowSummary: list rows may show source type, count, age, severity, due window, safe reason code, and primary action; raw message text, medical answers, photos, nutrition details, and private notes require opening the source workflow with source permission.
  • Only one current priority item exists per visible workspace and coach-client relationship.
  • Score inputs must be deterministic and explainable. Do not ship opaque model scores in the first version.
  • Package tier affects expected response cadence and applicable signals only. It never multiplies a client's score downward because they are on a lower-paid package.
  • Priority payloads store safe summaries and source references only. Sensitive answers, photos, nutrition details, and private notes stay in source contexts.
ModelOwned ByImportant MethodsEmits
CoachPriorityItemCoachWorkflowrecalculate(), applyManualPin(), suppressUntil(), clearWhenNoSignals(), markViewed()CoachPriorityItemChanged, CoachPriorityItemCleared
CoachPrioritySignalCoachWorkflowcreateFromSource(), refresh(), expire(), weightContribution()None
CoachPriorityPolicyCoachWorkflowcalculateScore(), deriveTier(), selectPrimaryAction(), selectReasons(), canSuppress()None
CoachTaskCoachWorkflowProvides unresolved task count, oldest due task, severity, coach ownership, and deep-link target.CoachTaskCreated, CoachTaskResolved, CoachTaskSnoozed
ClientRiskFlagCoachWorkflowProvides active risk severity, evidence summary, recovery status, and intervention action.ClientRiskFlagRaised, ClientRiskFlagResolved
CoachClientRelationshipCoachClientManagementReferenced by id for workspace, client, coach, lifecycle status, and visibility.CoachClientRelationshipPaused, CoachClientRelationshipEnded

Table Structure

Persist current ranking, normalized signals, daily snapshots, and coach-specific preferences. Keep raw source data in owning contexts.

V1 baseline Scoring Defaults

  • Risk flags: critical risk is a hard CRITICAL override; high contributes 70; medium 45; low 20; recovering 15.
  • Open tasks: urgent contributes 45; high 30; normal 15; low 5, capped at 80 total task points.
  • Due dates: due today contributes 10; overdue under 24 hours 20; overdue 1-3 days 35; overdue over 3 days 50, capped at 70.
  • Message reply gaps: 24 hours contributes 15; 48 hours 30; 72 hours or more 45.
  • Onboarding blockers: skipped blocking questionnaire 55; profile ready for review 35; onboarding stalled 25.
  • Activity and progress: adherence drift or missed logs contribute 25-45 based on severity; progress stall contributes 35 when risk rules deem the data fresh.
  • Manual pin adds a temporary 20-point display boost, cannot create CRITICAL, and expires within 7 days.

Visibility & Notifications

  • Default list shows CRITICAL, HIGH, MEDIUM, LOW, and WATCHLIST; CLEAR clients are hidden unless the coach filters or searches for them.
  • High threshold crossings update dashboard badges, mobile tabs, cached list pages, and daily digest entries. They do not send push notifications in Public V1.
  • Critical threshold crossings can create a rate-limited coach push notification when the underlying source policy allows push.
  • Package tier can tighten expected reply windows or add package-specific signals, but lower-tier clients are never automatically ranked below higher-tier clients.
  • Daily snapshots retain safe row summaries for 90 days, then keep monthly aggregate counts for 12 months and delete row-level snapshot details.
TablePurposeImportant ColumnsConstraints & Indexes
coach_priority_itemsCurrent client-level ranking read model. coach_user_id is the V1 baseline ownership field.id, workspace_id, relationship_id, client_user_id, coach_user_id, status, priority_tier, rank_score, rank_position, top_reason, reason_codes jsonb, score_breakdown jsonb, primary_action, primary_source_context, primary_source_type, primary_source_id, next_due_at, latest_activity_at, manual_pin_until, suppressed_until, last_scored_at, created_at, updated_atUnique (workspace_id, relationship_id); index (workspace_id, status, rank_score desc, next_due_at); index (workspace_id, coach_user_id, status, rank_score desc); default query excludes CLEAR, archived, ended relationships, and future suppressions unless requested.
coach_priority_signalsNormalized score inputs from tasks, risk flags, communication, activity, onboarding, and manual actions.id, workspace_id, relationship_id, signal_type, status, severity, weight, source_context, source_type, source_id, dedupe_key, reason_code, payload jsonb, occurred_at, expires_at, resolved_at, created_at, updated_atPartial unique (workspace_id, relationship_id, dedupe_key) where status is ACTI1E; index (workspace_id, relationship_id, status); index (source_context, source_type, source_id); payload contains safe summary only.
coach_priority_eventsAppend-only history for scoring transitions, manual pins, suppressions, views, and projector activity.id, priority_item_id, event_type, actor_user_id, source_event_id, metadata jsonb, created_atIndex (priority_item_id, created_at); idempotency unique on (priority_item_id, source_event_id, event_type) when source event is present.
coach_priority_snapshotsImmutable daily ranking rows for digest, reporting, and support review.id, workspace_id, snapshot_date, timezone, relationship_id, rank_position, priority_tier, rank_score, top_reasons jsonb, created_atUnique (workspace_id, snapshot_date, relationship_id); index (workspace_id, snapshot_date, rank_position); no application soft delete.
coach_priority_view_preferencesPer-coach default filters.id, workspace_id, user_id, default_tier_filter, show_suppressed, show_clear_clients, updated_atUnique (workspace_id, user_id); safe to recreate from defaults if deleted.

Note

Use plain UUID references across bounded contexts. Priority view may validate relationship visibility through application services and read models, but it must not navigate or mutate Profile, Forms, Programs, Nutrition, Communication, or Analytics aggregates.

Lifecycle Policy

Priority state is derived, recalculable, and explainable. Operational history is retained separately from current ranking.

Current Item Lifecycle

  • ACTI1E when one or more active signals, overdue commitments, unresolved tasks, active risk flags, or manual pins require attention.
  • WATCHLIST when signals are weak but still useful for monitoring, such as early adherence drift or recent onboarding delay.
  • SUPPRESSED when a coach intentionally hides a low-priority item until a future timestamp.
  • CLEAR when no active signals remain and no manual pin is active.
  • ARCHI1ED when the coach-client relationship is ended, revoked, deleted, or hidden by retention policy.

Signal Lifecycle

  • Signals are created or refreshed from source events and are idempotent by source dedupe key.
  • Signals resolve when source tasks, risks, messages, or activity gaps resolve.
  • Time-windowed signals expire through a cleanup job and trigger score recalculation.
  • Manual pin signals expire at manual_pin_until; suppression expires at suppressed_until.
  • Source deletion or privacy redaction removes sensitive payload fields but keeps minimal score history when retention policy allows it.

Retention & Visibility

  • Current priority items are recalculable and can be rebuilt from active tasks, active flags, recent activity summaries, and manual events.
  • Priority events are retained for 90 days for operational support and scoring audit, then trimmed to minimal support metadata unless workspace policy is stricter.
  • Daily snapshots preserve only safe reason summaries, not raw medical, photo, nutrition, or message content; row-level snapshots are retained for 90 days and monthly aggregates for 12 months.
  • Ended relationships are removed from default views immediately and archived by relationship status projector.

Score Stability

  • Incremental recalculation updates the affected relationship after each source event.
  • Workspace-level rank positions can be recomputed asynchronously after item scores change.
  • Daily snapshots freeze the morning ranking used for digest and comparison.
  • Tie-breakers are deterministic: score desc, next due asc, critical risk first, latest source event desc, relationship id asc.

API Contracts

Coach-facing APIs read the projection and record coach-specific priority actions. Projector ingestion stays internal.

EndpointPurposeRequestResponse
GET /api/v1/workspaces/{workspaceId}/coach-priorityList current priority items for the coach's visible relationships.Query: tier, status, hasRisk, dueBefore, includeSuppressed, cursor.Cursor page of CoachPriorityListItemDto with safe client card, tier, score, reasons, due summary, primary action, and source route.
GET /api/v1/workspaces/{workspaceId}/coach-priority/summaryReturn counts for dashboard widgets, mobile tabs, and daily digest headers.Optional tier and due-window filters.Counts by tier, overdue, high risk, unanswered message, onboarding blocker, suppressed, and clear.
GET /api/v1/workspaces/{workspaceId}/coach-priority/{relationshipId}Read detail for one priority item.Authorized coach workspace session and visible relationship.Priority detail, score breakdown, active signals, related tasks, related risk flags, source routes, and available actions.
POST /api/v1/workspaces/{workspaceId}/coach-priority/{relationshipId}/viewedAudit that a coach opened a priority item.Idempotency key and optional surface context.Updated viewed timestamp and item summary.
POST /api/v1/workspaces/{workspaceId}/coach-priority/{relationshipId}/pinAdd or update a coach-specific manual follow-up pin.pinUntil, optional reason, idempotency key.Updated item with manual-pin reason and recalculated display order.
POST /api/v1/workspaces/{workspaceId}/coach-priority/{relationshipId}/suppressTemporarily hide a low-priority item from default views.suppressedUntil, reason, idempotency key.Suppressed item and updated summary counts.
POST /api/v1/workspaces/{workspaceId}/coach-priority/{relationshipId}/recalculateForce rebuild for one relationship for support/admin diagnostics.Support/admin actor, optional reason.Recalculated item and score diagnostics.
PUT /api/v1/workspaces/{workspaceId}/coach-priority/preferencesSave default filters for the current coach.Tier filter, show suppressed, show clear clients.Saved preferences.

Key Code Snippets

Implementation sketches for a TypeScript, NestJS, and Drizzle modular monolith. Priority view reads from source-owned state and writes only ranking projections, preference rows, and audit events.

Drizzle schema shape

apps/api/src/coach-workflow/db/coach-priority.schema.ts

ts
export const priorityItemStatus = pgEnum("coach_priority_item_status", [
  "ACTI1E",
  "WATCHLIST",
  "SUPPRESSED",
  "CLEAR",
  "ARCHI1ED",
]);

export const priorityTier = pgEnum("coach_priority_tier", [
  "CRITICAL",
  "HIGH",
  "MEDIUM",
  "LOW",
  "CLEAR",
]);

export const coachPriorityItems = pgTable("coach_priority_items", {
  id: uuid("id").primaryKey().defaultRandom(),
  workspaceId: uuid("workspace_id").notNull(),
  relationshipId: uuid("relationship_id").notNull(),
  clientUserId: uuid("client_user_id").notNull(),
  coachUserId: uuid("coach_user_id").notNull(),
  status: priorityItemStatus("status").notNull().default("CLEAR"),
  priorityTier: priorityTier("priority_tier").notNull().default("CLEAR"),
  rankScore: integer("rank_score").notNull().default(0),
  rankPosition: integer("rank_position"),
  topReason: text("top_reason"),
  reasonCodes: jsonb("reason_codes").$type<string[]>().notNull().default([]),
  scoreBreakdown: jsonb("score_breakdown").$type<PriorityScoreBreakdown>().notNull(),
  primaryAction: text("primary_action").$type<PriorityAction>().notNull().default("NO_ACTION"),
  primarySourceContext: text("primary_source_context"),
  primarySourceType: text("primary_source_type"),
  primarySourceId: uuid("primary_source_id"),
  nextDueAt: timestamp("next_due_at", { withTimezone: true }),
  latestActivityAt: timestamp("latest_activity_at", { withTimezone: true }),
  manualPinUntil: timestamp("manual_pin_until", { withTimezone: true }),
  suppressedUntil: timestamp("suppressed_until", { withTimezone: true }),
  lastScoredAt: timestamp("last_scored_at", { withTimezone: true }).notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
  relationshipUnique: uniqueIndex("coach_priority_items_relationship_uq")
    .on(table.workspaceId, table.relationshipId),
  workspaceRankIdx: index("coach_priority_items_workspace_rank_idx")
    .on(table.workspaceId, table.status, table.rankScore, table.nextDueAt),
  coachRankIdx: index("coach_priority_items_coach_rank_idx")
    .on(table.workspaceId, table.coachUserId, table.status, table.rankScore),
}));

export const coachPrioritySignals = pgTable("coach_priority_signals", {
  id: uuid("id").primaryKey().defaultRandom(),
  workspaceId: uuid("workspace_id").notNull(),
  relationshipId: uuid("relationship_id").notNull(),
  signalType: text("signal_type").$type<PrioritySignalType>().notNull(),
  status: text("status").$type<"ACTI1E" | "RESOL1ED" | "EXPIRED">().notNull().default("ACTI1E"),
  severity: text("severity").$type<PrioritySignalSeverity>().notNull().default("NORMAL"),
  weight: integer("weight").notNull().default(0),
  sourceContext: text("source_context").notNull(),
  sourceType: text("source_type").notNull(),
  sourceId: uuid("source_id").notNull(),
  dedupeKey: text("dedupe_key").notNull(),
  reasonCode: text("reason_code").notNull(),
  payload: jsonb("payload").$type<PrioritySignalPayload>().notNull(),
  occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }),
  resolvedAt: timestamp("resolved_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
  activeSignalUnique: uniqueIndex("coach_priority_signals_active_uq")
    .on(table.workspaceId, table.relationshipId, table.dedupeKey)
    .where(sql`${table.status} = 'ACTI1E'`),
  relationshipSignalIdx: index("coach_priority_signals_relationship_idx")
    .on(table.workspaceId, table.relationshipId, table.status),
  sourceLookupIdx: index("coach_priority_signals_source_idx")
    .on(table.sourceContext, table.sourceType, table.sourceId),
}));

Signal projector use case

apps/api/src/coach-workflow/use-cases/refresh-priority-signal.ts

ts
@Injectable()
export class RefreshCoachPrioritySignalUseCase {
  constructor(
    private readonly relationships: CoachClientRelationshipRepository,
    private readonly signals: CoachPrioritySignalRepository,
    private readonly priority: CoachPriorityItemRepository,
    private readonly policy: CoachPriorityPolicy,
    private readonly outbox: OutboxPort,
    private readonly tx: TransactionRunner,
    private readonly clock: Clock,
  ) {}

  async execute(command: RefreshPrioritySignalCommand): Promise<CoachPriorityItemDto> {
    return this.tx.run(async () => {
      const relationship = await this.relationships.getById(command.relationshipId);
      this.policy.assertRelationshipCanBeRanked(relationship);

      const signal = CoachPrioritySignal.fromSource({
        workspaceId: relationship.workspaceId,
        relationshipId: relationship.id,
        signalType: command.signalType,
        severity: command.severity,
        source: command.source,
        reasonCode: command.reasonCode,
        payload: command.safeSummary,
        occurredAt: command.occurredAt,
        expiresAt: command.expiresAt,
      });

      await this.signals.upsertActive(signal);

      const activeSignals = await this.signals.listActiveForRelationship(
        relationship.workspaceId,
        relationship.id,
      );
      const existing = await this.priority.getOrCreateForRelationship(relationship);
      const scored = this.policy.calculateScore({
        relationship,
        signals: activeSignals,
        previousItem: existing,
        now: this.clock.now(),
      });

      existing.recalculate(scored);
      await this.priority.save(existing);
      await this.outbox.publish(existing.pullDomainEvents());
      return CoachPriorityItemDto.fromDomain(existing);
    });
  }
}

Scoring policy

apps/api/src/coach-workflow/policies/coach-priority.policy.ts

ts
const PRIORITY_TIER_THRESHOLDS = {
  HIGH: 75,
  MEDIUM: 45,
  LOW: 1,
} as const;

const SIGNAL_SCORE_CAPS: Record<PrioritySignalType, number> = {
  OPEN_TASK: 80,
  RISK_FLAG: 100,
  O1ERDUE_ACTION: 70,
  MESSAGE_REPLY_GAP: 45,
  ONBOARDING_BLOCKER: 55,
  ACTI1ITY_DROP: 45,
  PROGRESS_STALL: 35,
  MANUAL_PIN: 20,
};

export class CoachPriorityPolicy {
  calculateScore(input: PriorityScoreInput): PriorityScoreResult {
    const taskScore = this.sumSignals(input.signals, "OPEN_TASK", 0, SIGNAL_SCORE_CAPS.OPEN_TASK);
    const riskScore = this.sumSignals(input.signals, "RISK_FLAG", 0, SIGNAL_SCORE_CAPS.RISK_FLAG);
    const dueScore = this.sumSignals(input.signals, "O1ERDUE_ACTION", 0, SIGNAL_SCORE_CAPS.O1ERDUE_ACTION);
    const messageScore = this.sumSignals(input.signals, "MESSAGE_REPLY_GAP", 0, SIGNAL_SCORE_CAPS.MESSAGE_REPLY_GAP);
    const activityScore = this.sumSignals(input.signals, "ACTI1ITY_DROP", 0, SIGNAL_SCORE_CAPS.ACTI1ITY_DROP);
    const progressScore = this.sumSignals(input.signals, "PROGRESS_STALL", 0, SIGNAL_SCORE_CAPS.PROGRESS_STALL);
    const onboardingScore = this.sumSignals(input.signals, "ONBOARDING_BLOCKER", 0, SIGNAL_SCORE_CAPS.ONBOARDING_BLOCKER);
    const manualScore = this.activeManualPinBoost(input.previousItem, input.now);

    const rawScore = taskScore + riskScore + dueScore + messageScore +
      activityScore + progressScore + onboardingScore + manualScore;
    const rankScore = Math.min(100, rawScore);

    const reasons = this.selectReasons(input.signals, input.previousItem);
    const tier = this.deriveTier(rankScore, input.signals);
    const primary = this.selectPrimaryAction(input.signals);

    return {
      rankScore,
      tier,
      status: this.deriveStatus(rankScore, input.signals, input.previousItem, input.now),
      reasons,
      primaryAction: primary.action,
      primarySource: primary.source,
      scoreBreakdown: {
        tasks: taskScore,
        risk: riskScore,
        due: dueScore,
        communication: messageScore,
        activity: activityScore,
        progress: progressScore,
        onboarding: onboardingScore,
        manual: manualScore,
      },
    };
  }

  deriveTier(score: number, signals: CoachPrioritySignal[]): PriorityTier {
    if (signals.some((signal) => signal.severity === "CRITICAL")) return "CRITICAL";
    if (score >= PRIORITY_TIER_THRESHOLDS.HIGH) return "HIGH";
    if (score >= PRIORITY_TIER_THRESHOLDS.MEDIUM) return "MEDIUM";
    if (score >= PRIORITY_TIER_THRESHOLDS.LOW) return "LOW";
    return "CLEAR";
  }

  canSuppress(item: CoachPriorityItem): boolean {
    return item.priorityTier !== "CRITICAL" && item.rankScore < 75;
  }

  private sumSignals(
    signals: CoachPrioritySignal[],
    type: PrioritySignalType,
    min: number,
    max: number,
  ): number {
    const value = signals
      .filter((signal) => signal.signalType === type && signal.status === "ACTI1E")
      .reduce((sum, signal) => sum + signal.weightContribution(), 0);
    return Math.max(min, Math.min(max, value));
  }
}

List query service

apps/api/src/coach-workflow/queries/list-coach-priority.ts

ts
@Injectable()
export class ListCoachPriorityQuery {
  constructor(
    private readonly priority: CoachPriorityReadRepository,
    private readonly relationships: CoachClientRelationshipReadRepository,
    private readonly policy: CoachPriorityAccessPolicy,
  ) {}

  async execute(query: ListCoachPriorityRequest, actor: Actor): Promise<CursorPage<CoachPriorityListItemDto>> {
    actor.requireRole("COACH");
    actor.requireWorkspaceMembership(query.workspaceId, ["OWNER", "COACH"]);

    const visibility = await this.relationships.getVisibilityScope({
      workspaceId: query.workspaceId,
      actorUserId: actor.userId,
      requestedScope: "COACH",
    });

    this.policy.assertCanList(actor, query, visibility);

    const page = await this.priority.list({
      workspaceId: query.workspaceId,
      relationshipIds: visibility.relationshipIds,
      tiers: query.tiers,
      statuses: query.statuses,
      includeSuppressed: query.includeSuppressed,
      dueBefore: query.dueBefore,
      cursor: query.cursor,
      limit: query.limit,
    });

    return page.map((item) => CoachPriorityListItemDto.fromReadModel(item, actor.permissions));
  }
}

Events & Background Jobs

Priority view consumes task, risk, relationship, activity, and communication events. It emits ranking events for dashboards, badges, and digest jobs.

Consumed Events

  • CoachTaskCreated, CoachTaskRefreshed, CoachTaskResolved, CoachTaskDismissed, CoachTaskSnoozed: update open task and overdue signals.
  • ClientRiskFlagRaised, ClientRiskFlagRefreshed, ClientRiskFlagResolved: update risk signals and top reason selection.
  • ClientMessageNeedsReply, ClientMessageReplied: update communication gap signals.
  • CheckInMissed, DailyLogMissed, WorkoutSkipped, MeasurementTrendUpdated: update adherence and progress signals.
  • ClientProfileReadyForReview, OnboardingQuestionnaireSkipped, ClientOnboardingCompleted: update onboarding and profile readiness signals.
  • CoachClientRelationshipPaused, CoachClientRelationshipEnded: update visibility and archive behavior.

Emitted Events

  • CoachPrioritySignalCreated: audit normalized signal creation.
  • CoachPriorityItemChanged: update dashboard counts, mobile badges, and cached list pages.
  • CoachPriorityTierRaised: update badges, cache, and digest entries for high crossings; critical crossings may push only when source policy allows it.
  • CoachPriorityItemViewed: record that a coach reviewed the item.
  • CoachPriorityItemPinned and CoachPriorityItemSuppressed: record coach-specific triage decisions.
  • CoachPriorityDailySnapshotCreated: feed digest and reporting jobs.

Jobs

  • Incremental projector processes source events and refreshes the affected relationship.
  • Hourly overdue sweep updates due-date and message-reply-gap signals.
  • Daily workspace snapshot job freezes ranking by workspace timezone for digest and reporting, retaining row-level snapshots for 90 days.
  • Monthly snapshot aggregation job keeps count-level metrics for 12 months and deletes expired daily row details.
  • Expired signal cleanup resolves stale time-windowed signals and recalculates affected items.
  • Relationship lifecycle projector archives priority items for ended or revoked relationships.
  • Backfill job rebuilds current items from active tasks, active flags, recent activity summaries, and manual events.

Priority Inputs

  • Open task count, highest task severity, and oldest overdue task from the coach task inbox.
  • Active risk flag severity, recovery status, and evidence freshness from risk flags.
  • Unanswered message age and client reply SLA from communication.
  • Recent missed logs, skipped workouts, check-in gaps, and progress drift from activity summaries.
  • Onboarding status, profile readiness, package-derived SLA expectations, and relationship status.
  • Manual pin and suppression state from coach-specific priority actions.

Permissions & Access Rules

Priority view can reveal sensitive operational context even when it uses safe summaries. Every query must enforce workspace, role, relationship, and source-level visibility.

Coach

  • Must have active workspace membership and visibility into the coach-client relationship.
  • Coach can list priority items for relationships they can see in their workspace.
  • Can open source workflows only when they also have source-specific permission such as forms, nutrition, media, messages, or risk flags.
  • Can pin or suppress only visible relationships and only within Public V1 limits: pin defaults to 24 hours, custom pin max is 7 days, and suppression cannot hide critical items.
  • Cannot suppress critical items.

Client & System

  • Clients never read priority score, rank, or internal coach reasons.
  • Client-facing actions are created through source workflows such as messages, nudges, check-in requests, or plan updates.
  • System projectors can create, refresh, clear, archive, snapshot, and rebuild priority items through internal service accounts.
  • System workers must use idempotency keys based on source event id and source dedupe key.

Web, Coach Mobile, Client Mobile

Coach surfaces share API contracts but optimize the triage interaction for their context.

Coach Web

  • Primary dense triage table with priority tier, score, reasons, due summary, client status, and primary action.
  • Supports saved filters, search, reason drilldown, source deep links, and side-panel detail.
  • Can open task inbox filtered to the selected client or source category.
  • Can pin, suppress, mark viewed, and navigate to message, risk, profile, form, workout, nutrition, or program workflows.

Coach Mobile

  • Action-first ranked list grouped by critical, high, due today, and watchlist.
  • Shows safe reasons and one primary action per client; deeper review opens mobile-native source screens where available.
  • Supports message, nudge, pin, suppress, and open task actions. Complex review can defer to web when no mobile workflow exists yet.
  • Refreshes on foreground, critical/source-approved push notification receipt, and manual pull-to-refresh. Offline mode can show cached rankings as read-only.

Client Mobile

  • Does not display coach priority rank, score, internal reasons, or suppression state.
  • Receives only explicit client-facing actions such as messages, nudges, questionnaire requests, plan changes, or check-in prompts.
  • Offline client actions update priority only after sync reaches the backend and source events are projected.
  • Client privacy settings and data deletion policies must redact priority summaries when source records are redacted.

Test Scenarios & V1 Baseline Decisions

Validate deterministic scoring, projection idempotency, permissions, source-boundary behavior, notifications, and retention before implementation.

Tests

  • Domain: scoring combines task, risk, due-date, communication, activity, onboarding, and manual-pin inputs into expected tiers.
  • Domain: seeded score weights produce HIGH at 75+, MEDIUM at 45-74, LOW at 1-44, and CLEAR at 0.
  • Domain: critical signals cannot be suppressed and always produce CRITICAL tier regardless of manual state.
  • Domain: manual pins default to 24 hours, cannot exceed 7 days, and add only the 20-point manual display boost.
  • Domain: expired signals clear the item when no other active signals remain.
  • Projection: repeated source event refreshes one active signal and one priority item instead of duplicating rows.
  • Projection: task resolution, risk recovery, message reply, and onboarding completion lower or clear the affected relationship.
  • Projection: package tier changes adjust applicable SLA signals without lowering any lower-tier client by default.
  • API: coach cannot list relationships outside workspace or relationship visibility.
  • API: reason summaries redact source-sensitive detail when actor lacks source permission.
  • API: pin, suppress, viewed, and recalculate endpoints enforce idempotency keys and audit events.
  • API: default list excludes clear clients, but explicit filters and search can include them.
  • Notification: high crossings update badges and daily digest without push; critical crossings push only through source-approved rate limits.
  • Integration: daily snapshot freezes order by workspace timezone and remains unchanged after later same-day events.
  • Retention: daily snapshots purge row details after 90 days and preserve monthly aggregate counts for 12 months.
  • Web E2E: coach opens Priority view, filters high priority, opens a client, resolves related task, and sees rank update.
  • Coach mobile E2E: coach opens critical item, sends message, pins follow-up, and sees cached read-only ranking while offline.
  • Performance: list endpoint returns first page under target latency with thousands of active relationships in a workspace.

Resolved Decisions

  • Initial tiers: any active critical signal is CRITICAL; score 75+ is HIGH; 45-74 is MEDIUM; 1-44 is LOW; 0 is CLEAR.
  • Manual pins: default to 24 hours, offer 24-hour, 3-day, and 7-day presets, allow custom web expiration up to 7 days, and cannot hide critical risks.
  • Snapshots: keep daily row-level safe summaries for 90 days, then retain monthly aggregate counts for 12 months.
  • Default visibility: hide CLEAR clients by default for all coaches; make them available through search or an explicit filter.
  • Safe row details: show source type, age, severity, counts, due window, safe reason code, and primary action only. Raw source content stays behind source-specific permissions.
  • Notifications: high crossings update dashboard badges, mobile tabs, cached lists, and daily digests. Push is reserved for critical source-approved events.
  • Package influence: package tier can define expected SLA and applicable signals, but it never creates a blanket paid-plan priority multiplier.

CoachMe internal planning documentation.