feat: add review status, all-tasks-completed and cross-team message notifications
- Detect review state transitions (kanbanColumn → review) in status change notifications - Add "All tasks completed" notification when every task in a team reaches completed/deleted - Add "Cross-team message" notification with separate toggle (source: cross_team in inbox) - All three enabled by default with UI toggles in settings
This commit is contained in:
parent
48033ef701
commit
2131ad32d0
12 changed files with 172 additions and 8 deletions
|
|
@ -255,16 +255,27 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
const summary = msg.summary || extracted.summary;
|
const summary = msg.summary || extracted.summary;
|
||||||
const msgId = msg.timestamp ?? String(prevCount + i);
|
const msgId = msg.timestamp ?? String(prevCount + i);
|
||||||
|
|
||||||
|
// Cross-team messages get their own event type and per-type toggle
|
||||||
|
const isCrossTeam = msg.source === 'cross_team';
|
||||||
|
const eventType: 'lead_inbox' | 'user_inbox' | 'cross_team_message' = isCrossTeam
|
||||||
|
? 'cross_team_message'
|
||||||
|
: isLeadInbox
|
||||||
|
? 'lead_inbox'
|
||||||
|
: 'user_inbox';
|
||||||
|
const effectiveSuppressToast = isCrossTeam
|
||||||
|
? !config.notifications.enabled || !config.notifications.notifyOnCrossTeamMessage
|
||||||
|
: suppressToast;
|
||||||
|
|
||||||
void notificationManager
|
void notificationManager
|
||||||
.addTeamNotification({
|
.addTeamNotification({
|
||||||
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
teamEventType: eventType,
|
||||||
teamName,
|
teamName,
|
||||||
teamDisplayName,
|
teamDisplayName,
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
summary,
|
summary,
|
||||||
body: extracted.body,
|
body: extracted.body,
|
||||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||||
suppressToast,
|
suppressToast: effectiveSuppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ function validateNotificationsSection(
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
'notifyOnTaskComments',
|
'notifyOnTaskComments',
|
||||||
'notifyOnTaskCreated',
|
'notifyOnTaskCreated',
|
||||||
|
'notifyOnAllTasksCompleted',
|
||||||
|
'notifyOnCrossTeamMessage',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -185,6 +187,18 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnTaskCreated = value;
|
result.notifyOnTaskCreated = value;
|
||||||
break;
|
break;
|
||||||
|
case 'notifyOnAllTasksCompleted':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnAllTasksCompleted = value;
|
||||||
|
break;
|
||||||
|
case 'notifyOnCrossTeamMessage':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnCrossTeamMessage = value;
|
||||||
|
break;
|
||||||
case 'statusChangeOnlySolo':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ export interface DetectedError {
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
| 'task_created'
|
| 'task_created'
|
||||||
|
| 'all_tasks_completed'
|
||||||
|
| 'cross_team_message'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ export interface NotificationConfig {
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
/** Whether to show native OS notifications when a new task is created */
|
/** Whether to show native OS notifications when a new task is created */
|
||||||
notifyOnTaskCreated: boolean;
|
notifyOnTaskCreated: boolean;
|
||||||
|
/** Whether to show native OS notifications when all tasks in a team are completed */
|
||||||
|
notifyOnAllTasksCompleted: boolean;
|
||||||
|
/** Whether to show native OS notifications for cross-team messages */
|
||||||
|
notifyOnCrossTeamMessage: boolean;
|
||||||
/** Only notify on status changes in solo teams (no teammates) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -267,6 +271,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
notifyOnTaskCreated: true,
|
notifyOnTaskCreated: true,
|
||||||
|
notifyOnAllTasksCompleted: true,
|
||||||
|
notifyOnCrossTeamMessage: true,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export type TeamEventType =
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
| 'task_created'
|
| 'task_created'
|
||||||
|
| 'all_tasks_completed'
|
||||||
|
| 'cross_team_message'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
|
|
||||||
|
|
@ -65,6 +67,8 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
||||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||||
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
||||||
|
all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' },
|
||||||
|
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
|
||||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export interface SafeConfig {
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
notifyOnTaskCreated: boolean;
|
notifyOnTaskCreated: boolean;
|
||||||
|
notifyOnAllTasksCompleted: boolean;
|
||||||
|
notifyOnCrossTeamMessage: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -183,6 +185,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||||
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
||||||
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
||||||
|
notifyOnAllTasksCompleted:
|
||||||
|
displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
||||||
|
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,8 @@ export function useSettingsHandlers({
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
notifyOnTaskCreated: true,
|
notifyOnTaskCreated: true,
|
||||||
|
notifyOnAllTasksCompleted: true,
|
||||||
|
notifyOnCrossTeamMessage: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PartyPopper,
|
PartyPopper,
|
||||||
CirclePlus,
|
CirclePlus,
|
||||||
|
CheckCircle2,
|
||||||
|
GitBranch,
|
||||||
Send,
|
Send,
|
||||||
Users,
|
Users,
|
||||||
Volume2,
|
Volume2,
|
||||||
|
|
@ -37,7 +39,7 @@ import type { NotificationTrigger } from '@renderer/types/data';
|
||||||
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Notification targets span workflow status plus the explicit review axis. */
|
/** Notification targets span workflow status plus the explicit review axis. */
|
||||||
type NotifiableStatus = TeamTaskStatus | Extract<TeamReviewState, 'needsFix' | 'approved'>;
|
type NotifiableStatus = TeamTaskStatus | Extract<TeamReviewState, 'review' | 'needsFix' | 'approved'>;
|
||||||
|
|
||||||
// Snooze duration options
|
// Snooze duration options
|
||||||
const SNOOZE_OPTIONS = [
|
const SNOOZE_OPTIONS = [
|
||||||
|
|
@ -66,6 +68,8 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnStatusChange'
|
| 'notifyOnStatusChange'
|
||||||
| 'notifyOnTaskComments'
|
| 'notifyOnTaskComments'
|
||||||
| 'notifyOnTaskCreated'
|
| 'notifyOnTaskCreated'
|
||||||
|
| 'notifyOnAllTasksCompleted'
|
||||||
|
| 'notifyOnCrossTeamMessage'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -306,6 +310,28 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="All tasks completed"
|
||||||
|
description="Notify when every task in a team reaches completed status"
|
||||||
|
icon={<CheckCircle2 className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnAllTasksCompleted}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnAllTasksCompleted', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Cross-team message notifications"
|
||||||
|
description="Notify when a message arrives from another team"
|
||||||
|
icon={<GitBranch className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnCrossTeamMessage}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnCrossTeamMessage', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{/* Task Status Change Notifications — nested within team card */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
@ -447,6 +473,7 @@ export const NotificationsSection = ({
|
||||||
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
||||||
{ value: 'in_progress', label: 'Started' },
|
{ value: 'in_progress', label: 'Started' },
|
||||||
{ value: 'completed', label: 'Completed' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
{ value: 'needsFix', label: 'Needs Fixes' },
|
{ value: 'needsFix', label: 'Needs Fixes' },
|
||||||
{ value: 'approved', label: 'Approved' },
|
{ value: 'approved', label: 'Approved' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ const notifiedClarificationTaskKeys = new Set<string>();
|
||||||
const notifiedStatusChangeKeys = new Set<string>();
|
const notifiedStatusChangeKeys = new Set<string>();
|
||||||
const notifiedCommentKeys = new Set<string>();
|
const notifiedCommentKeys = new Set<string>();
|
||||||
const notifiedCreatedTaskKeys = new Set<string>();
|
const notifiedCreatedTaskKeys = new Set<string>();
|
||||||
|
const notifiedAllCompletedTeams = new Set<string>();
|
||||||
|
|
||||||
let isFirstFetchAllTasks = true;
|
let isFirstFetchAllTasks = true;
|
||||||
|
|
||||||
|
|
@ -182,10 +183,11 @@ function detectStatusChangeNotifications(
|
||||||
const taskKanbanColumn = getTaskKanbanColumn(task);
|
const taskKanbanColumn = getTaskKanbanColumn(task);
|
||||||
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
||||||
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
||||||
|
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
|
||||||
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix';
|
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix';
|
||||||
|
|
||||||
const statusChanged = oldTask.status !== task.status;
|
const statusChanged = oldTask.status !== task.status;
|
||||||
if (!statusChanged && !becameApproved && !becameNeedsFix) continue;
|
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
||||||
|
|
||||||
if (onlySolo) {
|
if (onlySolo) {
|
||||||
const team = teamByName[task.teamName];
|
const team = teamByName[task.teamName];
|
||||||
|
|
@ -193,18 +195,24 @@ function detectStatusChangeNotifications(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the effective status for notification matching
|
// Resolve the effective status for notification matching
|
||||||
const effectiveStatus = becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : task.status;
|
const effectiveStatus = becameApproved
|
||||||
|
? 'approved'
|
||||||
|
: becameReview
|
||||||
|
? 'review'
|
||||||
|
: becameNeedsFix
|
||||||
|
? 'needsFix'
|
||||||
|
: task.status;
|
||||||
if (!statuses.includes(effectiveStatus)) continue;
|
if (!statuses.includes(effectiveStatus)) continue;
|
||||||
|
|
||||||
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
|
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
|
||||||
if (notifiedStatusChangeKeys.has(key)) continue;
|
if (notifiedStatusChangeKeys.has(key)) continue;
|
||||||
notifiedStatusChangeKeys.add(key);
|
notifiedStatusChangeKeys.add(key);
|
||||||
|
|
||||||
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
|
const fromLabel = becameApproved ? 'Completed' : becameReview ? 'Completed' : oldTask.status;
|
||||||
fireStatusChangeNotification(
|
fireStatusChangeNotification(
|
||||||
task,
|
task,
|
||||||
fromLabel,
|
fromLabel,
|
||||||
becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : undefined,
|
becameApproved ? 'approved' : becameReview ? 'review' : becameNeedsFix ? 'needsFix' : undefined,
|
||||||
!statusChangeEnabled
|
!statusChangeEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +229,7 @@ function fireStatusChangeNotification(
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
deleted: 'Deleted',
|
deleted: 'Deleted',
|
||||||
|
review: 'Review',
|
||||||
needsFix: 'Needs Fixes',
|
needsFix: 'Needs Fixes',
|
||||||
approved: 'Approved',
|
approved: 'Approved',
|
||||||
};
|
};
|
||||||
|
|
@ -329,6 +338,64 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectAllTasksCompletedNotification(
|
||||||
|
oldTasks: GlobalTask[],
|
||||||
|
newTasks: GlobalTask[],
|
||||||
|
notifyEnabled: boolean
|
||||||
|
): void {
|
||||||
|
// Group tasks by team
|
||||||
|
const teamTasks = new Map<string, GlobalTask[]>();
|
||||||
|
for (const task of newTasks) {
|
||||||
|
const list = teamTasks.get(task.teamName) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
teamTasks.set(task.teamName, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [teamName, tasks] of teamTasks) {
|
||||||
|
if (tasks.length === 0) continue;
|
||||||
|
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
||||||
|
if (!allCompleted) {
|
||||||
|
// Reset so we can notify again if tasks become all-completed later
|
||||||
|
notifiedAllCompletedTeams.delete(teamName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (notifiedAllCompletedTeams.has(teamName)) continue;
|
||||||
|
|
||||||
|
// Check that at least one task was NOT completed before (real transition)
|
||||||
|
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
||||||
|
const wasAlreadyAllCompleted =
|
||||||
|
oldTeamTasks.length > 0 &&
|
||||||
|
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
||||||
|
if (wasAlreadyAllCompleted) {
|
||||||
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
|
fireAllTasksCompletedNotification(tasks[0], tasks.length, !notifyEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireAllTasksCompletedNotification(
|
||||||
|
sampleTask: GlobalTask,
|
||||||
|
taskCount: number,
|
||||||
|
suppressToast: boolean
|
||||||
|
): void {
|
||||||
|
void api.teams
|
||||||
|
?.showMessageNotification({
|
||||||
|
teamName: sampleTask.teamName,
|
||||||
|
teamDisplayName: sampleTask.teamDisplayName,
|
||||||
|
from: 'system',
|
||||||
|
to: 'user',
|
||||||
|
summary: `All ${taskCount} tasks completed`,
|
||||||
|
body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
|
||||||
|
teamEventType: 'all_tasks_completed',
|
||||||
|
dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
|
||||||
|
suppressToast,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function collectTaskChangeInvalidationState(
|
function collectTaskChangeInvalidationState(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
prevTasks: TeamData['tasks'],
|
prevTasks: TeamData['tasks'],
|
||||||
|
|
@ -888,6 +955,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||||
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
||||||
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
||||||
|
const notifyOnAllCompleted =
|
||||||
|
get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
|
||||||
|
detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
|
||||||
} else {
|
} else {
|
||||||
// Initial load — seed the Sets to prevent false notifications on next update
|
// Initial load — seed the Sets to prevent false notifications on next update
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|
@ -901,6 +971,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (getTaskKanbanColumn(task) === 'approved') {
|
if (getTaskKanbanColumn(task) === 'approved') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
}
|
}
|
||||||
|
if (getTaskKanbanColumn(task) === 'review') {
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||||
|
}
|
||||||
// Seed comment keys to prevent false notifications
|
// Seed comment keys to prevent false notifications
|
||||||
for (const comment of task.comments ?? []) {
|
for (const comment of task.comments ?? []) {
|
||||||
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
||||||
|
|
@ -908,6 +981,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Seed created task keys to prevent false notifications
|
// Seed created task keys to prevent false notifications
|
||||||
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
}
|
}
|
||||||
|
// Seed all-completed teams
|
||||||
|
const teamTasksMap = new Map<string, GlobalTask[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = teamTasksMap.get(task.teamName) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
teamTasksMap.set(task.teamName, list);
|
||||||
|
}
|
||||||
|
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||||
|
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
||||||
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export interface DetectedError {
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
| 'task_created'
|
| 'task_created'
|
||||||
|
| 'all_tasks_completed'
|
||||||
|
| 'cross_team_message'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||||
|
|
@ -274,6 +276,10 @@ export interface AppConfig {
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
/** Whether to show native OS notifications when a new task is created */
|
/** Whether to show native OS notifications when a new task is created */
|
||||||
notifyOnTaskCreated: boolean;
|
notifyOnTaskCreated: boolean;
|
||||||
|
/** Whether to show native OS notifications when all tasks in a team are completed */
|
||||||
|
notifyOnAllTasksCompleted: boolean;
|
||||||
|
/** Whether to show native OS notifications for cross-team messages */
|
||||||
|
notifyOnCrossTeamMessage: boolean;
|
||||||
/** Only notify on status changes in solo teams (no teammates) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
|
||||||
|
|
@ -713,7 +713,7 @@ export interface TeamMessageNotificationData {
|
||||||
/** Optional sender color for visual context. */
|
/** Optional sender color for visual context. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Team event sub-type for notification categorization. */
|
/** Team event sub-type for notification categorization. */
|
||||||
teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment' | 'task_created';
|
teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment' | 'task_created' | 'all_tasks_completed';
|
||||||
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ describe('buildDetectedErrorFromTeam', () => {
|
||||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||||
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
||||||
|
all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' },
|
||||||
|
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
|
||||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue