Skip to content

Commit 6298d32

Browse files
committed
feat: implement Phase 6 - Advanced Features (Part 1)
Enhanced Due Date Templates: - Added 6 enhanced quick presets in date picker - Templates: Today 5PM, Tomorrow 5PM, In 3 Days, End of Week, Next Monday, End of Month - Changed grid layout from 2 columns to 3 columns for better UX - All templates include specific times for consistency Calendar Export (.ics): Backend: - Implemented generateBoardCalendar method in ReportService - Created iCalendar format generator with proper escaping - Maps card priority to iCal priority (1-9 scale) - Maps card status to iCal status (COMPLETED/CONFIRMED) - Includes full card details: title, description, list, status, priority - Added GET /api/v1/reports/board/:boardId/calendar endpoint - Returns proper .ics file with Content-Type: text/calendar Frontend: - Added "Export (.ics)" button to CalendarView toolbar - Downloads .ics file for import into Google Calendar, Outlook, Apple Calendar - Includes all cards with due dates from the board Keyboard Shortcuts: - Created useDueDateShortcuts hook for quick date setting - Shortcuts: D+T (today), D+N (tomorrow), D+W (week), D+M (month), D+3 (3 days), D+X (clear) - Built KeyboardShortcutsHelp component with visual guide - Added shortcuts help button to BoardView header - Popover displays all available shortcuts with visual keys Technical Details: - iCalendar RFC 5545 compliant format - Proper date formatting (YYYYMMDDTHHMMSSZ) - Special character escaping for iCal text fields - UID format: card-id@sprinty.app - Includes metadata: DTSTAMP, CREATED, LAST-MODIFIED
1 parent 082bd81 commit 6298d32

File tree

8 files changed

+336
-12
lines changed

8 files changed

+336
-12
lines changed

api/src/modules/reports/report.controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,31 @@ export class ReportController {
144144
return reply.code(500).send({ error: "Internal server error" });
145145
}
146146
}
147+
148+
/**
149+
* GET /api/v1/reports/board/:boardId/calendar
150+
* Generate board calendar export (.ics)
151+
*/
152+
async generateBoardCalendar(
153+
request: FastifyRequest<{
154+
Params: BoardReportParams;
155+
}>,
156+
reply: FastifyReply
157+
) {
158+
try {
159+
const { boardId } = request.params;
160+
const ical = await this.service.generateBoardCalendar(boardId);
161+
162+
reply
163+
.header("Content-Type", "text/calendar; charset=utf-8")
164+
.header(
165+
"Content-Disposition",
166+
`attachment; filename="board-${boardId}-calendar.ics"`
167+
)
168+
.send(ical);
169+
} catch (error) {
170+
request.log.error(error);
171+
return reply.code(500).send({ error: "Internal server error" });
172+
}
173+
}
147174
}

api/src/modules/reports/report.route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,19 @@ export default async function reportRoutes(fastify: FastifyInstance) {
7070
},
7171
controller.generateUserActivityReport.bind(controller)
7272
);
73+
74+
// Board calendar export
75+
fastify.get(
76+
"/board/:boardId/calendar",
77+
{
78+
schema: {
79+
description: "Generate board calendar export (iCalendar .ics format)",
80+
tags: ["Reports"],
81+
params: Type.Object({
82+
boardId: Type.String({ format: "uuid" }),
83+
}),
84+
},
85+
},
86+
controller.generateBoardCalendar.bind(controller)
87+
);
7388
}

api/src/modules/reports/report.service.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,92 @@ export class ReportService {
190190

191191
return this.generateCSV(activities, headers);
192192
}
193+
194+
/**
195+
* Format date to iCalendar format (YYYYMMDDTHHMMSSZ)
196+
*/
197+
private formatICalDate(date: Date): string {
198+
return date
199+
.toISOString()
200+
.replace(/[-:]/g, "")
201+
.replace(/\.\d{3}/, "");
202+
}
203+
204+
/**
205+
* Escape special characters for iCalendar format
206+
*/
207+
private escapeICalText(text: string): string {
208+
return text
209+
.replace(/\\/g, "\\\\")
210+
.replace(/;/g, "\\;")
211+
.replace(/,/g, "\\,")
212+
.replace(/\n/g, "\\n");
213+
}
214+
215+
/**
216+
* Generate board calendar export (.ics)
217+
*/
218+
async generateBoardCalendar(boardId: string): Promise<string> {
219+
const cards = await this.knex("cards")
220+
.select(
221+
"cards.id",
222+
"cards.title",
223+
"cards.description",
224+
"cards.status",
225+
"cards.priority",
226+
"cards.due_date",
227+
"cards.created_at",
228+
"cards.updated_at",
229+
"lists.title as list_title",
230+
"boards.title as board_title"
231+
)
232+
.join("lists", "cards.list_id", "lists.id")
233+
.join("boards", "lists.board_id", "boards.id")
234+
.where("lists.board_id", boardId)
235+
.whereNotNull("cards.due_date")
236+
.orderBy("cards.due_date", "asc");
237+
238+
const now = new Date();
239+
const icalLines = [
240+
"BEGIN:VCALENDAR",
241+
"VERSION:2.0",
242+
"PRODID:-//Sprinty//Board Calendar Export//EN",
243+
"CALSCALE:GREGORIAN",
244+
"METHOD:PUBLISH",
245+
`X-WR-CALNAME:${this.escapeICalText(cards[0]?.board_title || "Sprinty Board")}`,
246+
"X-WR-TIMEZONE:UTC",
247+
];
248+
249+
for (const card of cards) {
250+
const dueDate = new Date(card.due_date);
251+
const description = card.description
252+
? `${this.escapeICalText(card.description)}\\n\\nList: ${this.escapeICalText(card.list_title)}\\nStatus: ${card.status || "N/A"}\\nPriority: ${card.priority || "N/A"}`
253+
: `List: ${this.escapeICalText(card.list_title)}\\nStatus: ${card.status || "N/A"}\\nPriority: ${card.priority || "N/A"}`;
254+
255+
icalLines.push("BEGIN:VEVENT");
256+
icalLines.push(`UID:${card.id}@sprinty.app`);
257+
icalLines.push(`DTSTAMP:${this.formatICalDate(now)}`);
258+
icalLines.push(`DTSTART:${this.formatICalDate(dueDate)}`);
259+
icalLines.push(`DTEND:${this.formatICalDate(dueDate)}`);
260+
icalLines.push(`SUMMARY:${this.escapeICalText(card.title)}`);
261+
icalLines.push(`DESCRIPTION:${description}`);
262+
icalLines.push(`LOCATION:${this.escapeICalText(card.list_title)}`);
263+
icalLines.push(`CREATED:${this.formatICalDate(new Date(card.created_at))}`);
264+
icalLines.push(`LAST-MODIFIED:${this.formatICalDate(new Date(card.updated_at))}`);
265+
266+
// Set priority based on card priority
267+
const icalPriority = card.priority === "critical" ? "1" : card.priority === "high" ? "3" : card.priority === "medium" ? "5" : "9";
268+
icalLines.push(`PRIORITY:${icalPriority}`);
269+
270+
// Set status
271+
const icalStatus = card.status === "completed" ? "COMPLETED" : "CONFIRMED";
272+
icalLines.push(`STATUS:${icalStatus}`);
273+
274+
icalLines.push("END:VEVENT");
275+
}
276+
277+
icalLines.push("END:VCALENDAR");
278+
279+
return icalLines.join("\r\n");
280+
}
193281
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Keyboard } from "lucide-react";
2+
import {
3+
Popover,
4+
PopoverContent,
5+
PopoverTrigger,
6+
} from "@/components/ui/popover";
7+
import { Button } from "@/components/ui/button";
8+
9+
export const KeyboardShortcutsHelp = () => {
10+
const shortcuts = [
11+
{ keys: ["D", "T"], description: "Set due date to today 5PM" },
12+
{ keys: ["D", "N"], description: "Set due date to tomorrow 5PM" },
13+
{ keys: ["D", "W"], description: "Set due date to end of week" },
14+
{ keys: ["D", "M"], description: "Set due date to end of month" },
15+
{ keys: ["D", "3"], description: "Set due date to 3 days from now" },
16+
{ keys: ["D", "X"], description: "Clear due date" },
17+
];
18+
19+
return (
20+
<Popover>
21+
<PopoverTrigger asChild>
22+
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground">
23+
<Keyboard className="h-4 w-4" />
24+
Shortcuts
25+
</Button>
26+
</PopoverTrigger>
27+
<PopoverContent className="w-80" align="end">
28+
<div className="space-y-3">
29+
<div>
30+
<h3 className="font-semibold text-sm mb-2">Due Date Keyboard Shortcuts</h3>
31+
<p className="text-xs text-muted-foreground mb-3">
32+
Press keys in sequence to quickly set due dates
33+
</p>
34+
</div>
35+
<div className="space-y-2">
36+
{shortcuts.map((shortcut, index) => (
37+
<div key={index} className="flex items-center justify-between text-sm">
38+
<div className="flex items-center gap-1">
39+
{shortcut.keys.map((key, keyIndex) => (
40+
<div key={keyIndex} className="flex items-center">
41+
<kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
42+
{key}
43+
</kbd>
44+
{keyIndex < shortcut.keys.length - 1 && (
45+
<span className="mx-1 text-muted-foreground"></span>
46+
)}
47+
</div>
48+
))}
49+
</div>
50+
<span className="text-xs text-muted-foreground ml-3">
51+
{shortcut.description}
52+
</span>
53+
</div>
54+
))}
55+
</div>
56+
<div className="pt-2 border-t">
57+
<p className="text-xs text-muted-foreground italic">
58+
Note: Shortcuts work when not typing in input fields
59+
</p>
60+
</div>
61+
</div>
62+
</PopoverContent>
63+
</Popover>
64+
);
65+
};

client/src/components/ui/date-picker.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,28 @@ export function DatePicker({
4949
const now = DateTime.now();
5050
return [
5151
{
52-
label: "Today",
53-
date: now.endOf("day").toJSDate(),
52+
label: "Today 5PM",
53+
date: now.set({ hour: 17, minute: 0, second: 0 }).toJSDate(),
5454
},
5555
{
56-
label: "Tomorrow",
57-
date: now.plus({ days: 1 }).endOf("day").toJSDate(),
56+
label: "Tomorrow 5PM",
57+
date: now.plus({ days: 1 }).set({ hour: 17, minute: 0, second: 0 }).toJSDate(),
5858
},
5959
{
60-
label: "Next Week",
61-
date: now.plus({ weeks: 1 }).endOf("day").toJSDate(),
60+
label: "In 3 Days",
61+
date: now.plus({ days: 3 }).set({ hour: 17, minute: 0, second: 0 }).toJSDate(),
6262
},
6363
{
64-
label: "Next Month",
65-
date: now.plus({ months: 1 }).endOf("day").toJSDate(),
64+
label: "End of Week",
65+
date: now.endOf("week").set({ hour: 17, minute: 0, second: 0 }).toJSDate(),
66+
},
67+
{
68+
label: "Next Monday",
69+
date: now.plus({ weeks: 1 }).startOf("week").set({ hour: 9, minute: 0, second: 0 }).toJSDate(),
70+
},
71+
{
72+
label: "End of Month",
73+
date: now.endOf("month").set({ hour: 17, minute: 0, second: 0 }).toJSDate(),
6674
},
6775
];
6876
}, []);
@@ -141,16 +149,16 @@ export function DatePicker({
141149
<>
142150
<div className="space-y-2">
143151
<Label className="text-xs font-medium text-muted-foreground">
144-
Quick Select
152+
Quick Presets
145153
</Label>
146-
<div className="grid grid-cols-2 gap-2">
154+
<div className="grid grid-cols-3 gap-2">
147155
{quickPresets.map((preset) => (
148156
<Button
149157
key={preset.label}
150158
variant="outline"
151159
size="sm"
152160
onClick={() => handleQuickPreset(preset.date)}
153-
className="h-8"
161+
className="h-8 text-xs"
154162
>
155163
{preset.label}
156164
</Button>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useEffect } from "react";
2+
import { DateTime } from "luxon";
3+
import { toISOString } from "@/lib/dateUtils";
4+
5+
export interface DueDateShortcuts {
6+
enableShortcuts: boolean;
7+
onSetDueDate: (date: string) => void;
8+
}
9+
10+
/**
11+
* Hook to enable keyboard shortcuts for setting due dates
12+
*
13+
* Shortcuts:
14+
* - d + t: Set to today at 5 PM
15+
* - d + n: Set to tomorrow at 5 PM
16+
* - d + w: Set to end of week at 5 PM
17+
* - d + m: Set to end of month at 5 PM
18+
* - d + 3: Set to 3 days from now at 5 PM
19+
* - d + x: Clear due date
20+
*/
21+
export const useDueDateShortcuts = ({ enableShortcuts, onSetDueDate }: DueDateShortcuts) => {
22+
useEffect(() => {
23+
if (!enableShortcuts) return;
24+
25+
let lastKey = "";
26+
let timeout: NodeJS.Timeout;
27+
28+
const handleKeyPress = (event: KeyboardEvent) => {
29+
// Ignore shortcuts when typing in input fields
30+
const target = event.target as HTMLElement;
31+
if (
32+
target.tagName === "INPUT" ||
33+
target.tagName === "TEXTAREA" ||
34+
target.isContentEditable
35+
) {
36+
return;
37+
}
38+
39+
const key = event.key.toLowerCase();
40+
41+
// Start shortcut sequence with 'd'
42+
if (key === "d" && lastKey === "") {
43+
lastKey = "d";
44+
clearTimeout(timeout);
45+
// Reset after 1.5 seconds of inactivity
46+
timeout = setTimeout(() => {
47+
lastKey = "";
48+
}, 1500);
49+
return;
50+
}
51+
52+
// Handle second key in sequence
53+
if (lastKey === "d") {
54+
const now = DateTime.now();
55+
let dueDate: DateTime | null = null;
56+
57+
switch (key) {
58+
case "t": // Today at 5 PM
59+
dueDate = now.set({ hour: 17, minute: 0, second: 0 });
60+
break;
61+
case "n": // Tomorrow at 5 PM
62+
dueDate = now.plus({ days: 1 }).set({ hour: 17, minute: 0, second: 0 });
63+
break;
64+
case "w": // End of week at 5 PM
65+
dueDate = now.endOf("week").set({ hour: 17, minute: 0, second: 0 });
66+
break;
67+
case "m": // End of month at 5 PM
68+
dueDate = now.endOf("month").set({ hour: 17, minute: 0, second: 0 });
69+
break;
70+
case "3": // 3 days from now at 5 PM
71+
dueDate = now.plus({ days: 3 }).set({ hour: 17, minute: 0, second: 0 });
72+
break;
73+
case "x": // Clear due date
74+
onSetDueDate("");
75+
lastKey = "";
76+
clearTimeout(timeout);
77+
return;
78+
default:
79+
lastKey = "";
80+
clearTimeout(timeout);
81+
return;
82+
}
83+
84+
if (dueDate) {
85+
onSetDueDate(toISOString(dueDate.toJSDate()));
86+
}
87+
88+
lastKey = "";
89+
clearTimeout(timeout);
90+
}
91+
};
92+
93+
window.addEventListener("keydown", handleKeyPress);
94+
95+
return () => {
96+
window.removeEventListener("keydown", handleKeyPress);
97+
clearTimeout(timeout);
98+
};
99+
}, [enableShortcuts, onSetDueDate]);
100+
};

client/src/pages/BoardView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
1616
import { Calendar, AlertCircle } from "lucide-react";
1717
import { useDueDateAnalytics } from "@/hooks/useAnalytics";
1818
import { Badge } from "@/components/ui/badge";
19+
import { KeyboardShortcutsHelp } from "@/components/board/KeyboardShortcutsHelp";
1920

2021
const BoardView = () => {
2122
const { board_id } = useParams();
@@ -90,6 +91,7 @@ const BoardView = () => {
9091
{dueDateAnalytics.summary.overdue} Overdue
9192
</Badge>
9293
)}
94+
<KeyboardShortcutsHelp />
9395
<Button
9496
variant="outline"
9597
onClick={() => navigate(`/board/${board_id}/calendar`)}

0 commit comments

Comments
 (0)