Skip to content

Commit 777c0db

Browse files
github-actions[bot]chasprowebdevMarfuen
authored
feat(app): Auto mark tasks as todo when review period starts (#1546)
* feat(app): create a scheduled task for Recurring policy * feat(db): add reviewDate to Task table * feat(app): add Review Date on Task Properties * feat(tasks): add a scheduled task for Recurring Task * fix(app): set reviewDate once task gets moved to done * fix(app): make task reviewDate read-only * fix(app): minor style issue on task reviewDate input --------- Co-authored-by: chasprowebdev <chasgarciaprowebdev@gmail.com> Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 92b2dc9 commit 777c0db

5 files changed

Lines changed: 417 additions & 68 deletions

File tree

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx

Lines changed: 16 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import { useState } from 'react';
1414
import { TaskStatusIndicator } from '../../components/TaskStatusIndicator';
1515
import { PropertySelector } from './PropertySelector';
1616
import { DEPARTMENT_COLORS, taskDepartments, taskFrequencies, taskStatuses } from './constants';
17-
import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover';
18-
import { Calendar } from '@comp/ui/calendar';
1917
import { format } from 'date-fns';
2018

2119
interface TaskPropertiesSidebarProps {
@@ -40,15 +38,6 @@ export function TaskPropertiesSidebar({
4038
orgId,
4139
}: TaskPropertiesSidebarProps) {
4240
const [dropdownOpen, setDropdownOpen] = useState(false);
43-
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
44-
const [tempDate, setTempDate] = useState<Date | undefined>(undefined);
45-
46-
// Function to handle date confirmation
47-
const handleDateConfirm = (date: Date | undefined) => {
48-
setTempDate(date);
49-
setIsDatePickerOpen(false);
50-
handleUpdateTask({ reviewDate: date });
51-
};
5241

5342
return (
5443
<aside className="hidden w-full shrink-0 flex-col md:w-64 md:border-l md:pt-8 md:pl-8 lg:flex lg:w-72">
@@ -102,6 +91,7 @@ export function TaskPropertiesSidebar({
10291
onSelect={(selectedStatus) => {
10392
handleUpdateTask({
10493
status: selectedStatus as TaskStatus,
94+
reviewDate: selectedStatus === 'done' ? new Date() : task.reviewDate,
10595
});
10696
}}
10797
trigger={
@@ -297,63 +287,21 @@ export function TaskPropertiesSidebar({
297287
{/* Review Date Selector */}
298288
<div className="flex items-center justify-between text-sm">
299289
<span className="text-muted-foreground">Review Date</span>
300-
<Popover
301-
open={isDatePickerOpen}
302-
onOpenChange={(open) => {
303-
setIsDatePickerOpen(open);
304-
if (!open) {
305-
setTempDate(undefined);
306-
}
307-
}}
308-
>
309-
<PopoverTrigger asChild>
310-
<Button
311-
type="button"
312-
variant="ghost"
313-
className="flex h-auto w-auto items-center justify-end p-0 px-1 hover:bg-transparent data-[state=open]:bg-transparent"
314-
>
315-
{tempDate ? (
316-
format(tempDate, 'M/d/yyyy')
317-
) : task.reviewDate ? (
318-
format(new Date(task.reviewDate), 'M/d/yyyy')
319-
) : (
320-
<span className="text-muted-foreground px-1">Select ...</span>
321-
)}
322-
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
323-
</Button>
324-
</PopoverTrigger>
325-
<PopoverContent className="w-auto p-0" align="end">
326-
<Calendar
327-
mode="single"
328-
selected={
329-
tempDate || (task.reviewDate ? new Date(task.reviewDate) : undefined)
330-
}
331-
onSelect={(date) => setTempDate(date)}
332-
disabled={(date) => date <= new Date()}
333-
initialFocus
334-
/>
335-
<div className="mt-4 flex justify-end gap-2 px-4 pb-2">
336-
<Button
337-
type="button"
338-
size="sm"
339-
variant="outline"
340-
onClick={() => {
341-
setIsDatePickerOpen(false);
342-
setTempDate(undefined);
343-
}}
344-
>
345-
Cancel
346-
</Button>
347-
<Button
348-
type="button"
349-
size="sm"
350-
onClick={() => handleDateConfirm(tempDate)}
351-
>
352-
Confirm Date
353-
</Button>
354-
</div>
355-
</PopoverContent>
356-
</Popover>
290+
<div className="flex items-center justify-end p-0 px-1 min-h-[2.25rem]">
291+
{task.reviewDate ? (
292+
<>
293+
<span className="font-medium">
294+
{format(new Date(task.reviewDate), 'M/d/yyyy')}
295+
</span>
296+
<CalendarIcon className="ml-2 h-4 w-4 opacity-50" />
297+
</>
298+
) : (
299+
<>
300+
<span className="text-muted-foreground px-1 font-medium">None</span>
301+
<CalendarIcon className="ml-2 h-4 w-4 opacity-50" />
302+
</>
303+
)}
304+
</div>
357305
</div>
358306
</div>
359307
</aside>
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { db } from '@db';
2+
import { sendTaskReviewNotificationEmail } from '@trycompai/email';
3+
import { logger, schedules } from '@trigger.dev/sdk';
4+
5+
export const taskSchedule = schedules.task({
6+
id: 'task-schedule',
7+
cron: '0 */12 * * *', // Every 12 hours
8+
maxDuration: 1000 * 60 * 10, // 10 minutes
9+
run: async () => {
10+
const now = new Date();
11+
12+
// Find all Done tasks that have a review date and frequency set
13+
const candidateTasks = await db.task.findMany({
14+
where: {
15+
status: 'done',
16+
reviewDate: {
17+
not: null,
18+
},
19+
frequency: {
20+
not: null,
21+
},
22+
},
23+
include: {
24+
organization: {
25+
select: {
26+
name: true,
27+
},
28+
},
29+
assignee: {
30+
include: {
31+
user: true,
32+
},
33+
},
34+
},
35+
});
36+
37+
// Helpers to compute next due date based on frequency
38+
const addDaysToDate = (date: Date, days: number) => {
39+
const result = new Date(date.getTime());
40+
result.setDate(result.getDate() + days);
41+
return result;
42+
};
43+
44+
const addMonthsToDate = (date: Date, months: number) => {
45+
const result = new Date(date.getTime());
46+
const originalDayOfMonth = result.getDate();
47+
result.setMonth(result.getMonth() + months);
48+
// Handle month rollover (e.g., Jan 31 + 1 month -> Feb 28/29)
49+
if (result.getDate() < originalDayOfMonth) {
50+
result.setDate(0);
51+
}
52+
return result;
53+
};
54+
55+
const overdueTasks = candidateTasks.filter((task) => {
56+
if (!task.reviewDate || !task.frequency) return false;
57+
58+
let nextDueDate: Date | null = null;
59+
switch (task.frequency) {
60+
case 'daily':
61+
nextDueDate = addDaysToDate(task.reviewDate, 1);
62+
break;
63+
case 'weekly':
64+
nextDueDate = addDaysToDate(task.reviewDate, 7);
65+
break;
66+
case 'monthly':
67+
nextDueDate = addMonthsToDate(task.reviewDate, 1);
68+
break;
69+
case 'quarterly':
70+
nextDueDate = addMonthsToDate(task.reviewDate, 3);
71+
break;
72+
case 'yearly':
73+
nextDueDate = addMonthsToDate(task.reviewDate, 12);
74+
break;
75+
default:
76+
nextDueDate = null;
77+
}
78+
79+
return nextDueDate !== null && nextDueDate <= now;
80+
});
81+
82+
logger.info(`Found ${overdueTasks.length} tasks past their computed review deadline`);
83+
84+
if (overdueTasks.length === 0) {
85+
return {
86+
success: true,
87+
totalTasksChecked: 0,
88+
updatedTasks: 0,
89+
message: 'No tasks found past their computed review deadline',
90+
};
91+
}
92+
93+
// Update all overdue tasks to "todo" status
94+
try {
95+
const taskIds = overdueTasks.map((task) => task.id);
96+
97+
const updateResult = await db.task.updateMany({
98+
where: {
99+
id: {
100+
in: taskIds,
101+
},
102+
},
103+
data: {
104+
status: 'todo',
105+
},
106+
});
107+
108+
109+
110+
// Log details about updated tasks
111+
overdueTasks.forEach((task) => {
112+
logger.info(
113+
`Updated task "${task.title}" (${task.id}) from org "${task.organization.name}" - frequency ${task.frequency} - last reviewed ${task.reviewDate?.toISOString()}`,
114+
);
115+
});
116+
117+
logger.info(`Successfully updated ${updateResult.count} tasks to "todo" status`);
118+
119+
// Build a map of admins by organization for targeted notifications
120+
const uniqueOrgIds = Array.from(new Set(overdueTasks.map((t) => t.organizationId)));
121+
const admins = await db.member.findMany({
122+
where: {
123+
organizationId: { in: uniqueOrgIds },
124+
isActive: true,
125+
// role is a comma-separated string sometimes
126+
role: { contains: 'admin' },
127+
},
128+
include: {
129+
user: true,
130+
},
131+
});
132+
133+
const adminsByOrgId = new Map<string, { email: string; name: string }[]>();
134+
admins.forEach((admin) => {
135+
const email = admin.user?.email;
136+
if (!email) return;
137+
const list = adminsByOrgId.get(admin.organizationId) ?? [];
138+
list.push({ email, name: admin.user.name ?? email });
139+
adminsByOrgId.set(admin.organizationId, list);
140+
});
141+
142+
// Rate limit: 2 emails per second
143+
const EMAIL_BATCH_SIZE = 2;
144+
const EMAIL_BATCH_DELAY_MS = 1000;
145+
146+
// Build a flat list of email jobs
147+
type EmailJob = {
148+
email: string;
149+
name: string;
150+
task: typeof overdueTasks[number];
151+
};
152+
const emailJobs: EmailJob[] = [];
153+
154+
// Helper to compute next due date again for email content
155+
const computeNextDueDate = (reviewDate: Date, frequency: string): Date | null => {
156+
switch (frequency) {
157+
case 'daily':
158+
return addDaysToDate(reviewDate, 1);
159+
case 'weekly':
160+
return addDaysToDate(reviewDate, 7);
161+
case 'monthly':
162+
return addMonthsToDate(reviewDate, 1);
163+
case 'quarterly':
164+
return addMonthsToDate(reviewDate, 3);
165+
case 'yearly':
166+
return addMonthsToDate(reviewDate, 12);
167+
default:
168+
return null;
169+
}
170+
};
171+
172+
for (const task of overdueTasks) {
173+
const recipients = new Map<string, string>(); // email -> name
174+
175+
// Assignee (if any)
176+
const assigneeEmail = task.assignee?.user?.email;
177+
if (assigneeEmail) {
178+
recipients.set(assigneeEmail, task.assignee?.user?.name ?? assigneeEmail);
179+
}
180+
181+
// Organization admins
182+
const orgAdmins = adminsByOrgId.get(task.organizationId) ?? [];
183+
orgAdmins.forEach((a) => recipients.set(a.email, a.name));
184+
185+
if (recipients.size === 0) {
186+
logger.info(`No recipients found for task ${task.id} (${task.title})`);
187+
continue;
188+
}
189+
190+
for (const [email, name] of recipients.entries()) {
191+
emailJobs.push({ email, name, task });
192+
}
193+
}
194+
195+
for (let i = 0; i < emailJobs.length; i += EMAIL_BATCH_SIZE) {
196+
const batch = emailJobs.slice(i, i + EMAIL_BATCH_SIZE);
197+
198+
await Promise.all(
199+
batch.map(async ({ email, name, task }) => {
200+
try {
201+
await sendTaskReviewNotificationEmail({
202+
email,
203+
userName: name,
204+
taskName: task.title,
205+
organizationName: task.organization.name,
206+
organizationId: task.organizationId,
207+
taskId: task.id,
208+
});
209+
logger.info(`Sent task review notification to ${email} for task ${task.id}`);
210+
} catch (emailError) {
211+
logger.error(`Failed to send review email to ${email} for task ${task.id}: ${emailError}`);
212+
}
213+
}),
214+
);
215+
216+
// Only delay if there are more emails to send
217+
if (i + EMAIL_BATCH_SIZE < emailJobs.length) {
218+
await new Promise((resolve) => setTimeout(resolve, EMAIL_BATCH_DELAY_MS));
219+
}
220+
}
221+
222+
return {
223+
success: true,
224+
totalTasksChecked: overdueTasks.length,
225+
updatedTasks: updateResult.count,
226+
updatedTaskIds: taskIds,
227+
message: `Updated ${updateResult.count} tasks past their review deadline`,
228+
};
229+
} catch (error) {
230+
logger.error(`Failed to update overdue tasks: ${error}`);
231+
232+
return {
233+
success: false,
234+
totalTasksChecked: overdueTasks.length,
235+
updatedTasks: 0,
236+
error: error instanceof Error ? error.message : String(error),
237+
message: 'Failed to update tasks past their review deadline',
238+
};
239+
}
240+
},
241+
});
242+
243+

0 commit comments

Comments
 (0)