improvement: users get notified whenever a task is done completely, not per step.

This commit is contained in:
2026-09-07 22:36:33 +03:30
parent e52701dba3
commit 880809fdbe
17 changed files with 79 additions and 29 deletions

View File

@@ -6,7 +6,7 @@ alwaysApply: false
# Tab badges (Cases / Tasks / Treatment)
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + completions.
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = case fully completed + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + case fully completed. Per-step `TASK_COMPLETED` is timeline-only (not badges or inbox).
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).

View File

@@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
## Emit sites (parallel to LabCaseActivity)
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, CASE_COMPLETED (all tasks in the case done — not each step), TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.

View File

@@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
## Models
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED`
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` (timeline only), `TASK_ASSIGNED`, `CASE_COMPLETED`
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
@@ -22,8 +22,8 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
| Org | Tab | Activity types |
|-----|-----|----------------|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED`**only lab cases for treatments the user provided** |
| LAB | Tasks | `CASE_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `CASE_COMPLETED`**only lab cases for treatments the user provided** |
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
@@ -43,7 +43,8 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
| First send | `treatments.service` `sendLabCase``CASE_SENT` |
| Comment | `lab-case-comments.service``CLINIC_COMMENT` / `LAB_COMMENT` |
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
| Task completed | `tasks.service` `updateStatus``TASK_COMPLETED` |
| Task completed (step) | `tasks.service` `updateStatus``TASK_COMPLETED` **activity only** (case timeline; not inbox or tab badges) |
| Case fully completed | `tasks.service` `updateStatus` when no in-progress tasks remain → `CASE_COMPLETED` activity + inbox |
| Task assigned | `cases.service` `assignTask``TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).

View File

@@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "LabCaseActivityType" ADD VALUE 'CASE_COMPLETED';
-- AlterEnum
ALTER TYPE "UserNotificationType" ADD VALUE 'CASE_COMPLETED';
-- Step-level TASK_COMPLETED inbox rows were spam; case-complete uses CASE_COMPLETED going forward.
DELETE FROM "UserNotification" WHERE type = 'TASK_COMPLETED';

View File

@@ -492,6 +492,7 @@ enum LabCaseActivityType {
CASE_AMENDED
TASK_COMPLETED
TASK_ASSIGNED
CASE_COMPLETED
}
enum LabCaseTabReadTarget {
@@ -508,6 +509,7 @@ enum UserNotificationType {
CASE_IMPORTANT
TASK_COMPLETED
TASK_ASSIGNED
CASE_COMPLETED
CONNECTION_REQUEST
STAFF_INVITE
}

View File

@@ -7,15 +7,15 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.CASE_IMPORTANT,
];
/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */
/** Lab Tasks tab — case fully completed, lab-side comments, and assignments (assignee-scoped in counts). */
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.CASE_COMPLETED,
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_ASSIGNED,
];
/** Clinic Treatment tab — visible lab comments and task progress. */
/** Clinic Treatment tab — visible lab comments and case fully completed. */
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.CASE_COMPLETED,
];

View File

@@ -303,6 +303,7 @@ export class TasksService {
}
}
let caseCompleted = false;
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
await this.labCaseActivity.record(
{
@@ -321,18 +322,37 @@ export class TasksService {
},
tx,
);
const remainingIncomplete = await tx.labCaseTask.count({
where: {
labCaseId: task.labCaseId,
status: { not: LabTaskStatus.COMPLETED },
},
});
caseCompleted = remainingIncomplete === 0;
if (caseCompleted) {
await this.labCaseActivity.record(
{
labCaseId: task.labCaseId,
type: LabCaseActivityType.CASE_COMPLETED,
actorUserId,
payload: { labCaseId: task.labCaseId },
},
tx,
);
}
}
return result;
return { result, caseCompleted };
});
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
if (updated.caseCompleted) {
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_COMPLETED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
type: UserNotificationType.CASE_COMPLETED,
href: `/cases?caseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TASKS_READ',
});
@@ -340,10 +360,10 @@ export class TasksService {
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.TASK_COMPLETED,
type: UserNotificationType.CASE_COMPLETED,
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: task.labCaseId,
});
@@ -353,11 +373,11 @@ export class TasksService {
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
atomicProsthesisCodes([updated.prosthesisTypeCode]),
atomicProsthesisCodes([updated.result.prosthesisTypeCode]),
locale,
);
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
return { success: true, data: this.mapTaskListItem(updated.result, prosthesisLabels) };
}
async listFilterOptions(

View File

@@ -846,6 +846,7 @@
"activityTaskCompleted": "{step} completed by {actor} · {date}",
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
"activityCaseImportant": "Marked important by {actor} · {date}",
"activityCaseCompleted": "All lab work completed by {actor} · {date}",
"activityCaseAmended": "Case updated by {actor} · {date}",
"activityGeneric": "Update · {date}",
"loadingHistory": "Loading history…",
@@ -1105,6 +1106,7 @@
"typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you",
"typeCaseCompleted": "Lab case completed",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification",

View File

@@ -847,6 +847,7 @@
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
"activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}",
"activityCaseCompleted": "تمام کارهای لابراتوار توسط {actor} تکمیل شد · {date}",
"activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}",
"activityGeneric": "به‌روزرسانی · {date}",
"loadingHistory": "در حال بارگذاری تاریخچه...",
@@ -1106,6 +1107,7 @@
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeCaseCompleted": "پرونده لابراتوار تکمیل شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان",

View File

@@ -846,6 +846,7 @@
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
"activityCaseCompleted": "Al het labwerk voltooid door {actor} · {date}",
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
"activityGeneric": "Update · {date}",
"loadingHistory": "Geschiedenis laden...",
@@ -1105,6 +1106,7 @@
"typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeCaseCompleted": "Labcase voltooid",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding",

View File

@@ -54,7 +54,7 @@ export default async function LocaleLayout({
setRequestLocale(locale);
const messages = await getMessages();
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();`;
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
const fontSans = isRtlLocale(locale)
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
@@ -65,6 +65,7 @@ export default async function LocaleLayout({
lang={localeHtmlLang(locale)}
dir={dir}
data-locale={locale}
data-theme="light"
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
style={{ ['--font-sans' as never]: fontSans }}
suppressHydrationWarning

View File

@@ -13,6 +13,7 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned',
CASE_COMPLETED: 'typeCaseCompleted',
CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite',
};
@@ -74,6 +75,12 @@ export function notificationContextLine(
if (clinicName) parts.push(clinicName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'CASE_COMPLETED':
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
else if (labName) parts.push(labName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'LAB_COMMENT_CLINIC':
if (patientName) parts.push(patientName);
if (labName) parts.push(labName);

View File

@@ -43,6 +43,8 @@ export function formatLabCaseActivityLine(
});
case 'CASE_IMPORTANT':
return t('activityCaseImportant', { actor, date });
case 'CASE_COMPLETED':
return t('activityCaseCompleted', { actor, date });
case 'CASE_AMENDED':
return t('activityCaseAmended', { actor, date });
default:

View File

@@ -3,14 +3,14 @@ export const THEME_STORAGE_KEY = 'dyolink-theme';
export type ThemeMode = 'light' | 'dark';
export function getStoredTheme(): ThemeMode {
if (typeof window === 'undefined') return 'dark';
if (typeof window === 'undefined') return 'light';
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dark') return v;
} catch {
/* ignore */
}
return 'dark';
return 'light';
}
export function applyTheme(mode: ThemeMode) {

View File

@@ -262,13 +262,17 @@ body {
}
html {
color-scheme: dark;
color-scheme: light;
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
/* Minimal RTL layer — refine incrementally. */
html[dir='rtl'] body {
direction: rtl;
@@ -338,9 +342,7 @@ select option {
}
:root[data-theme='dark'] select.form-select,
:root[data-theme='dark'] select,
:root:not([data-theme='light']) select.form-select,
:root:not([data-theme='light']) select {
:root[data-theme='dark'] select {
color-scheme: dark;
}
@@ -358,8 +360,7 @@ select option {
}
}
:root[data-theme='dark'] .surface-card,
:root:not([data-theme='light']) .surface-card {
:root[data-theme='dark'] .surface-card {
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
}

View File

@@ -5,7 +5,8 @@ export type LabCaseActivityType =
| 'CASE_IMPORTANT'
| 'CASE_AMENDED'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED';
| 'TASK_ASSIGNED'
| 'CASE_COMPLETED';
export interface LabCaseActivityItem {
id: string;

View File

@@ -6,6 +6,7 @@ export type UserNotificationType =
| 'CASE_IMPORTANT'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED'
| 'CASE_COMPLETED'
| 'CONNECTION_REQUEST'
| 'STAFF_INVITE';