This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add notifications and fix a ton of bugs

+868 -62
+2
.gitignore
··· 32 32 33 33 # Finder (MacOS) folder config 34 34 .DS_Store 35 + migrations 36 + local.db
local.db

This is a binary file and will not be displayed.

+3 -3
manifest.yaml
··· 4 4 background_color: "#617c68" 5 5 features: 6 6 app_home: 7 - home_tab_enabled: true 8 - messages_tab_enabled: false 9 - messages_tab_read_only_enabled: true 7 + home_tab_enabled: false 8 + messages_tab_enabled: true 9 + messages_tab_read_only_enabled: false 10 10 bot_user: 11 11 display_name: smokey 12 12 always_online: false
+810 -45
src/features/takes.ts
··· 2 2 import { slackApp } from "../index"; 3 3 import { db } from "../libs/db"; 4 4 import { takes as takesTable } from "../libs/schema"; 5 - import { eq, and, isNull } from "drizzle-orm"; 5 + import { eq, and, desc } from "drizzle-orm"; 6 + import TakesConfig from "../libs/config"; 6 7 7 8 type MessageResponse = { 8 9 blocks?: AnyMessageBlock[]; ··· 11 12 }; 12 13 13 14 const takes = async () => { 15 + // Helper function for pretty-printing time 16 + const prettyPrintTime = (ms: number): string => { 17 + const minutes = Math.round(ms / 60000); 18 + if (minutes < 2) { 19 + const seconds = Math.max(0, Math.round(ms / 1000)); 20 + return `${seconds} seconds`; 21 + } 22 + return `${minutes} minutes`; 23 + }; 24 + 14 25 // Helper functions for command actions 15 26 const getActiveTake = async (userId: string) => { 16 27 return db ··· 38 49 .limit(1); 39 50 }; 40 51 41 - const getCompletedTakes = async (userId: string) => { 52 + const getCompletedTakes = async (userId: string, limit = 5) => { 42 53 return db 43 54 .select() 44 55 .from(takesTable) ··· 47 58 eq(takesTable.userId, userId), 48 59 eq(takesTable.status, "completed"), 49 60 ), 61 + ) 62 + .orderBy(desc(takesTable.completedAt)) 63 + .limit(limit); 64 + }; 65 + 66 + // Check for paused sessions that have exceeded the max pause duration 67 + const expirePausedSessions = async () => { 68 + const now = new Date(); 69 + const pausedTakes = await db 70 + .select() 71 + .from(takesTable) 72 + .where(eq(takesTable.status, "paused")); 73 + 74 + for (const take of pausedTakes) { 75 + if (take.pausedAt) { 76 + const pausedDuration = 77 + (now.getTime() - take.pausedAt.getTime()) / (60 * 1000); // Convert to minutes 78 + 79 + // Send warning notification when getting close to expiration 80 + if ( 81 + pausedDuration > 82 + TakesConfig.MAX_PAUSE_DURATION - 83 + TakesConfig.NOTIFICATIONS 84 + .PAUSE_EXPIRATION_WARNING && 85 + !take.notifiedPauseExpiration 86 + ) { 87 + // Update notification flag 88 + await db 89 + .update(takesTable) 90 + .set({ 91 + notifiedPauseExpiration: true, 92 + }) 93 + .where(eq(takesTable.id, take.id)); 94 + 95 + // Send warning message 96 + try { 97 + const timeRemaining = Math.round( 98 + TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 99 + ); 100 + await slackApp.client.chat.postMessage({ 101 + channel: take.userId, 102 + text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`, 103 + }); 104 + } catch (error) { 105 + console.error( 106 + "Failed to send pause expiration warning:", 107 + error, 108 + ); 109 + } 110 + } 111 + 112 + // Auto-expire paused sessions that exceed the max pause duration 113 + if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) { 114 + await db 115 + .update(takesTable) 116 + .set({ 117 + status: "completed", 118 + completedAt: now, 119 + notes: take.notes 120 + ? `${take.notes} (Automatically completed due to pause timeout)` 121 + : "Automatically completed due to pause timeout", 122 + }) 123 + .where(eq(takesTable.id, take.id)); 124 + 125 + // Notify user that their session was auto-completed 126 + try { 127 + await slackApp.client.chat.postMessage({ 128 + channel: take.userId, 129 + text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.`, 130 + }); 131 + } catch (error) { 132 + console.error( 133 + "Failed to notify user of auto-completed session:", 134 + error, 135 + ); 136 + } 137 + } 138 + } 139 + } 140 + }; 141 + 142 + // Check for active sessions that are almost done 143 + const checkActiveSessions = async () => { 144 + const now = new Date(); 145 + const activeTakes = await db 146 + .select() 147 + .from(takesTable) 148 + .where(eq(takesTable.status, "active")); 149 + 150 + for (const take of activeTakes) { 151 + const endTime = new Date( 152 + take.startedAt.getTime() + 153 + take.durationMinutes * 60000 + 154 + (take.pausedTimeMs || 0), 50 155 ); 156 + 157 + const remainingMs = endTime.getTime() - now.getTime(); 158 + const remainingMinutes = remainingMs / 60000; 159 + 160 + if ( 161 + remainingMinutes <= 162 + TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING && 163 + remainingMinutes > 0 && 164 + !take.notifiedLowTime 165 + ) { 166 + await db 167 + .update(takesTable) 168 + .set({ notifiedLowTime: true }) 169 + .where(eq(takesTable.id, take.id)); 170 + 171 + console.log("Sending low time warning to user"); 172 + 173 + try { 174 + await slackApp.client.chat.postMessage({ 175 + channel: take.userId, 176 + text: `⏱️ Your takes session has less than ${TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING} minutes remaining.`, 177 + }); 178 + } catch (error) { 179 + console.error("Failed to send low time warning:", error); 180 + } 181 + } 182 + 183 + if (remainingMs <= 0) { 184 + await db 185 + .update(takesTable) 186 + .set({ 187 + status: "completed", 188 + completedAt: now, 189 + notes: take.notes 190 + ? `${take.notes} (Automatically completed - time expired)` 191 + : "Automatically completed - time expired", 192 + }) 193 + .where(eq(takesTable.id, take.id)); 194 + 195 + try { 196 + await slackApp.client.chat.postMessage({ 197 + channel: take.userId, 198 + text: "⏰ Your takes session has automatically completed because the time is up.", 199 + }); 200 + } catch (error) { 201 + console.error( 202 + "Failed to notify user of completed session:", 203 + error, 204 + ); 205 + } 206 + } 207 + } 51 208 }; 52 209 53 210 // Command action handlers 54 211 const handleStart = async ( 55 212 userId: string, 56 213 channelId: string, 214 + description?: string, 215 + durationMinutes?: number, 57 216 ): Promise<MessageResponse> => { 58 217 const activeTake = await getActiveTake(userId); 59 218 if (activeTake.length > 0) { 60 219 return { 61 - text: `You already have an active takes session! Use \`/takes status\` to check it.`, 220 + text: "You already have an active takes session! Use `/takes status` to check it.", 62 221 response_type: "ephemeral", 63 222 }; 64 223 } ··· 70 229 channelId, 71 230 status: "active", 72 231 startedAt: new Date(), 73 - durationMinutes: 5, // 5 minutes for testing (should be 90) 232 + durationMinutes: 233 + durationMinutes || TakesConfig.DEFAULT_SESSION_LENGTH, 234 + description: description || null, 235 + notifiedLowTime: false, 236 + notifiedPauseExpiration: false, 74 237 }; 75 238 76 239 await db.insert(takesTable).values(newTake); ··· 81 244 ); 82 245 const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`; 83 246 247 + const descriptionText = description 248 + ? `\n\n*Working on:* ${description}` 249 + : ""; 84 250 return { 85 - text: `🎬 Takes session started! You have ${newTake.durationMinutes} minutes until ${endTimeStr}.`, 86 - response_type: "in_channel", 251 + text: `🎬 Takes session started! You have ${prettyPrintTime(newTake.durationMinutes * 60000)} until ${endTimeStr}.${descriptionText}`, 252 + response_type: "ephemeral", 87 253 blocks: [ 88 254 { 89 255 type: "section", 90 256 text: { 91 257 type: "mrkdwn", 92 - text: `🎬 Takes session started! You have ${newTake.durationMinutes} minutes until ${endTimeStr}.`, 258 + text: `🎬 Takes session started!${descriptionText}`, 93 259 }, 94 260 }, 95 261 { 262 + type: "divider", 263 + }, 264 + { 265 + type: "context", 266 + elements: [ 267 + { 268 + type: "mrkdwn", 269 + text: `You have ${prettyPrintTime(newTake.durationMinutes * 60000)} left until ${endTimeStr}.`, 270 + }, 271 + ], 272 + }, 273 + { 96 274 type: "actions", 97 275 elements: [ 98 276 { 99 277 type: "button", 100 278 text: { 101 279 type: "plain_text", 280 + text: "✍️ edit", 281 + emoji: true, 282 + }, 283 + value: "edit", 284 + action_id: "takes_edit", 285 + }, 286 + { 287 + type: "button", 288 + text: { 289 + type: "plain_text", 102 290 text: "⏸️ Pause", 103 291 emoji: true, 104 292 }, ··· 116 304 action_id: "takes_stop", 117 305 style: "danger", 118 306 }, 307 + { 308 + type: "button", 309 + text: { 310 + type: "plain_text", 311 + text: "🔄 Refresh", 312 + emoji: true, 313 + }, 314 + value: "status", 315 + action_id: "takes_status", 316 + }, 119 317 ], 120 318 }, 121 319 ], ··· 144 342 .set({ 145 343 status: "paused", 146 344 pausedAt: new Date(), 345 + notifiedPauseExpiration: false, // Reset pause expiration notification 147 346 }) 148 347 .where(eq(takesTable.id, takeToUpdate.id)); 149 348 349 + // Calculate when the pause will expire 350 + const pauseExpires = new Date(); 351 + pauseExpires.setMinutes( 352 + pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION, 353 + ); 354 + const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`; 355 + 150 356 return { 151 - text: `⏸️ Takes session paused! Use \`/takes resume\` to continue.`, 152 - response_type: "in_channel", 357 + text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining. It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`, 358 + response_type: "ephemeral", 153 359 blocks: [ 154 360 { 155 361 type: "section", 156 362 text: { 157 363 type: "mrkdwn", 158 - text: `⏸️ Takes session paused!`, 364 + text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining.`, 159 365 }, 160 366 }, 161 367 { 368 + type: "divider", 369 + }, 370 + { 371 + type: "context", 372 + elements: [ 373 + { 374 + type: "mrkdwn", 375 + text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`, 376 + }, 377 + ], 378 + }, 379 + { 162 380 type: "actions", 163 381 elements: [ 164 382 { 165 383 type: "button", 166 384 text: { 167 385 type: "plain_text", 386 + text: "✍️ edit", 387 + emoji: true, 388 + }, 389 + value: "edit", 390 + action_id: "takes_edit", 391 + }, 392 + { 393 + type: "button", 394 + text: { 395 + type: "plain_text", 168 396 text: "▶️ Resume", 169 397 emoji: true, 170 398 }, ··· 182 410 action_id: "takes_stop", 183 411 style: "danger", 184 412 }, 413 + { 414 + type: "button", 415 + text: { 416 + type: "plain_text", 417 + text: "🔄 Refresh", 418 + emoji: true, 419 + }, 420 + value: "status", 421 + action_id: "takes_status", 422 + }, 185 423 ], 186 424 }, 187 425 ], ··· 220 458 status: "active", 221 459 pausedAt: null, 222 460 pausedTimeMs: totalPausedTime, 461 + notifiedLowTime: false, // Reset low time notification 223 462 }) 224 463 .where(eq(takesTable.id, pausedSession.id)); 225 464 } 226 465 466 + const endTime = new Date( 467 + new Date(pausedSession.startedAt).getTime() + 468 + pausedSession.durationMinutes * 60000 + 469 + (pausedSession.pausedTimeMs || 0), 470 + ); 471 + const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`; 472 + 227 473 return { 228 - text: `▶️ Takes session resumed!`, 229 - response_type: "in_channel", 474 + text: `▶️ Takes session resumed! You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining in your session.`, 475 + response_type: "ephemeral", 230 476 blocks: [ 231 477 { 232 478 type: "section", 233 479 text: { 234 480 type: "mrkdwn", 235 - text: `▶️ Takes session resumed!`, 481 + text: "▶️ Takes session resumed!", 236 482 }, 237 483 }, 238 484 { 485 + type: "divider", 486 + }, 487 + { 488 + type: "context", 489 + elements: [ 490 + { 491 + type: "mrkdwn", 492 + text: `You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining until ${endTimeStr}.`, 493 + }, 494 + ], 495 + }, 496 + { 239 497 type: "actions", 240 498 elements: [ 499 + { 500 + type: "button", 501 + text: { 502 + type: "plain_text", 503 + text: "✍️ edit", 504 + emoji: true, 505 + }, 506 + value: "edit", 507 + action_id: "takes_edit", 508 + }, 241 509 { 242 510 type: "button", 243 511 text: { ··· 259 527 action_id: "takes_stop", 260 528 style: "danger", 261 529 }, 530 + { 531 + type: "button", 532 + text: { 533 + type: "plain_text", 534 + text: "🔄 Refresh", 535 + emoji: true, 536 + }, 537 + value: "status", 538 + action_id: "takes_status", 539 + }, 262 540 ], 263 541 }, 264 542 ], ··· 267 545 268 546 const handleStop = async ( 269 547 userId: string, 548 + args?: string[], 270 549 ): Promise<MessageResponse | undefined> => { 271 550 const activeTake = await getActiveTake(userId); 272 551 ··· 286 565 return; 287 566 } 288 567 568 + // Extract notes if provided 569 + let notes = undefined; 570 + if (args && args.length > 1) { 571 + notes = args.slice(1).join(" "); 572 + } 573 + 289 574 await db 290 575 .update(takesTable) 291 576 .set({ 292 577 status: "completed", 293 578 completedAt: new Date(), 579 + ...(notes && { notes }), 294 580 }) 295 581 .where(eq(takesTable.id, pausedTakeToStop.id)); 296 582 } else { ··· 300 586 return; 301 587 } 302 588 589 + // Extract notes if provided 590 + let notes = undefined; 591 + if (args && args.length > 1) { 592 + notes = args.slice(1).join(" "); 593 + } 594 + 303 595 await db 304 596 .update(takesTable) 305 597 .set({ 306 598 status: "completed", 307 599 completedAt: new Date(), 600 + ...(notes && { notes }), 308 601 }) 309 602 .where(eq(takesTable.id, activeTakeToStop.id)); 310 603 } 311 604 312 605 return { 313 - text: `✅ Takes session completed! Thanks for your contribution.`, 314 - response_type: "in_channel", 606 + text: "✅ Takes session completed! I hope you had fun!", 607 + response_type: "ephemeral", 315 608 blocks: [ 316 609 { 317 610 type: "section", 318 611 text: { 319 612 type: "mrkdwn", 320 - text: `✅ Takes session completed! Thanks for your contribution.`, 613 + text: "✅ Takes session completed! I hope you had fun!", 321 614 }, 322 615 }, 323 616 { ··· 332 625 }, 333 626 value: "start", 334 627 action_id: "takes_start", 628 + }, 629 + { 630 + type: "button", 631 + text: { 632 + type: "plain_text", 633 + text: "📋 History", 634 + emoji: true, 635 + }, 636 + value: "history", 637 + action_id: "takes_history", 335 638 }, 336 639 ], 337 640 }, ··· 344 647 ): Promise<MessageResponse | undefined> => { 345 648 const activeTake = await getActiveTake(userId); 346 649 650 + // First, check for expired paused sessions 651 + await expirePausedSessions(); 652 + 347 653 if (activeTake.length > 0) { 348 654 const take = activeTake[0]; 349 655 if (!take) { ··· 360 666 endTime.setTime(endTime.getTime() + take.pausedTimeMs); 361 667 } 362 668 669 + const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`; 670 + 363 671 const now = new Date(); 364 672 const remainingMs = endTime.getTime() - now.getTime(); 365 - let remaining: string; 366 - if (remainingMs < 120000) { 367 - // Less than 2 minutes 368 - remaining = `${Math.max(0, Math.floor(remainingMs / 1000))} seconds`; 369 - } else { 370 - remaining = `${Math.max(0, Math.floor(remainingMs / 60000))} minutes`; 371 - } 673 + 674 + // Add description to display if present 675 + const descriptionText = take.description 676 + ? `\n\n*Working on:* ${take.description}` 677 + : ""; 372 678 373 679 return { 374 - text: `You have an active takes session with ${remaining} minutes remaining.`, 680 + text: `🎬 You have an active takes session with ${prettyPrintTime(remainingMs)} remaining.${descriptionText}`, 375 681 response_type: "ephemeral", 376 682 blocks: [ 377 683 { 378 684 type: "section", 379 685 text: { 380 686 type: "mrkdwn", 381 - text: `You have an active takes session with *${remaining}* remaining.`, 687 + text: `🎬 You have an active takes session${descriptionText}`, 382 688 }, 383 689 }, 384 690 { 691 + type: "divider", 692 + }, 693 + { 694 + type: "context", 695 + elements: [ 696 + { 697 + type: "mrkdwn", 698 + text: `You have ${prettyPrintTime(remainingMs)} remaining until ${endTimeStr}.`, 699 + }, 700 + ], 701 + }, 702 + { 385 703 type: "actions", 386 704 elements: [ 387 705 { 388 706 type: "button", 389 707 text: { 390 708 type: "plain_text", 709 + text: "✍️ edit", 710 + emoji: true, 711 + }, 712 + value: "edit", 713 + action_id: "takes_edit", 714 + }, 715 + { 716 + type: "button", 717 + text: { 718 + type: "plain_text", 391 719 text: "⏸️ Pause", 392 720 emoji: true, 393 721 }, ··· 404 732 value: "stop", 405 733 action_id: "takes_stop", 406 734 style: "danger", 735 + }, 736 + 737 + { 738 + type: "button", 739 + text: { 740 + type: "plain_text", 741 + text: "🔄 Refresh", 742 + emoji: true, 743 + }, 744 + value: "status", 745 + action_id: "takes_status", 407 746 }, 408 747 ], 409 748 }, ··· 415 754 const pausedTakeStatus = await getPausedTake(userId); 416 755 417 756 if (pausedTakeStatus.length > 0) { 757 + const pausedTake = pausedTakeStatus[0]; 758 + if (!pausedTake || !pausedTake.pausedAt) { 759 + return; 760 + } 761 + 762 + // Calculate how much time remains before auto-completion 763 + const now = new Date(); 764 + const pausedDuration = 765 + (now.getTime() - pausedTake.pausedAt.getTime()) / (60 * 1000); // In minutes 766 + const remainingPauseTime = Math.max( 767 + 0, 768 + TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 769 + ); 770 + 771 + // Format the pause timeout 772 + const pauseExpires = new Date(pausedTake.pausedAt); 773 + pauseExpires.setMinutes( 774 + pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION, 775 + ); 776 + const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`; 777 + 778 + // Add notes to display if present 779 + const noteText = pausedTake.notes 780 + ? `\n\n*Working on:* ${pausedTake.notes}` 781 + : ""; 782 + 418 783 return { 419 - text: `You have a paused takes session. Use \`/takes resume\` to continue.`, 784 + text: `⏸️ You have a paused takes session. It will auto-complete in ${remainingPauseTime.toFixed(1)} minutes if not resumed.`, 420 785 response_type: "ephemeral", 421 786 blocks: [ 422 787 { 423 788 type: "section", 424 789 text: { 425 790 type: "mrkdwn", 426 - text: `You have a paused takes session.`, 791 + text: `⏸️ Session paused! You have ${prettyPrintTime(pausedTake.durationMinutes * 60000)} remaining.`, 427 792 }, 428 793 }, 429 794 { 795 + type: "divider", 796 + }, 797 + { 798 + type: "context", 799 + elements: [ 800 + { 801 + type: "mrkdwn", 802 + text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`, 803 + }, 804 + ], 805 + }, 806 + { 430 807 type: "actions", 431 808 elements: [ 432 809 { ··· 450 827 action_id: "takes_stop", 451 828 style: "danger", 452 829 }, 830 + { 831 + type: "button", 832 + text: { 833 + type: "plain_text", 834 + text: "🔄 Refresh", 835 + emoji: true, 836 + }, 837 + value: "status", 838 + action_id: "takes_status", 839 + }, 453 840 ], 454 841 }, 455 842 ], ··· 458 845 459 846 // Check history of completed sessions 460 847 const completedSessions = await getCompletedTakes(userId); 848 + const takeTime = completedSessions.length 849 + ? (() => { 850 + const diffMs = 851 + new Date().getTime() - 852 + // @ts-expect-error - TS doesn't know that we are checking the length 853 + completedSessions[ 854 + completedSessions.length - 1 855 + ].startedAt.getTime(); 856 + 857 + const hours = Math.ceil(diffMs / (1000 * 60 * 60)); 858 + if (hours < 24) return `${hours} hours`; 859 + 860 + const weeks = Math.floor( 861 + diffMs / (1000 * 60 * 60 * 24 * 7), 862 + ); 863 + if (weeks > 0 && weeks < 4) return `${weeks} weeks`; 864 + 865 + const months = Math.floor( 866 + diffMs / (1000 * 60 * 60 * 24 * 30), 867 + ); 868 + return `${months} months`; 869 + })() 870 + : 0; 461 871 462 872 return { 463 - text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the past.`, 873 + text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`, 464 874 response_type: "ephemeral", 465 875 blocks: [ 466 876 { 467 877 type: "section", 468 878 text: { 469 879 type: "mrkdwn", 470 - text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the past.`, 880 + text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`, 471 881 }, 472 882 }, 473 883 { ··· 483 893 value: "start", 484 894 action_id: "takes_start", 485 895 }, 896 + { 897 + type: "button", 898 + text: { 899 + type: "plain_text", 900 + text: "📋 History", 901 + emoji: true, 902 + }, 903 + value: "history", 904 + action_id: "takes_history", 905 + }, 486 906 ], 487 907 }, 488 908 ], 489 909 }; 490 910 }; 491 911 912 + const handleHistory = async (userId: string): Promise<MessageResponse> => { 913 + // Get completed takes for the user 914 + const completedTakes = ( 915 + await getCompletedTakes(userId, TakesConfig.MAX_HISTORY_ITEMS) 916 + ).sort( 917 + (a, b) => 918 + (b.completedAt?.getTime() ?? 0) - 919 + (a.completedAt?.getTime() ?? 0), 920 + ); 921 + 922 + if (completedTakes.length === 0) { 923 + return { 924 + text: "You haven't completed any takes sessions yet.", 925 + response_type: "ephemeral", 926 + }; 927 + } 928 + 929 + // Create blocks for each completed take 930 + const historyBlocks: AnyMessageBlock[] = [ 931 + { 932 + type: "header", 933 + text: { 934 + type: "plain_text", 935 + text: `📋 Your most recent ${completedTakes.length} Takes Sessions`, 936 + emoji: true, 937 + }, 938 + }, 939 + ]; 940 + 941 + for (const take of completedTakes) { 942 + const startTime = new Date(take.startedAt); 943 + const endTime = take.completedAt || startTime; 944 + 945 + // Calculate duration in minutes 946 + const durationMs = endTime.getTime() - startTime.getTime(); 947 + const pausedMs = take.pausedTimeMs || 0; 948 + const activeDuration = Math.round((durationMs - pausedMs) / 60000); 949 + 950 + // Format dates 951 + const startDate = `<!date^${Math.floor(startTime.getTime() / 1000)}^{date_short_pretty} at {time}|${startTime.toLocaleString()}>`; 952 + const endDate = `<!date^${Math.floor(endTime.getTime() / 1000)}^{date_short_pretty} at {time}|${endTime.toLocaleString()}>`; 953 + 954 + const notes = take.notes ? `\n• Notes: ${take.notes}` : ""; 955 + const description = take.description 956 + ? `\n• Description: ${take.description}\n` 957 + : ""; 958 + 959 + historyBlocks.push({ 960 + type: "section", 961 + text: { 962 + type: "mrkdwn", 963 + text: `*Take on ${startDate}*\n${description}• Duration: ${activeDuration} minutes${ 964 + pausedMs > 0 965 + ? ` (+ ${Math.round(pausedMs / 60000)} minutes paused)` 966 + : "" 967 + }\n• Started: ${startDate}\n• Completed: ${endDate}${notes}`, 968 + }, 969 + }); 970 + 971 + // Add a divider between entries 972 + if (take !== completedTakes[completedTakes.length - 1]) { 973 + historyBlocks.push({ 974 + type: "divider", 975 + }); 976 + } 977 + } 978 + 979 + // Add actions block 980 + historyBlocks.push({ 981 + type: "actions", 982 + elements: [ 983 + { 984 + type: "button", 985 + text: { 986 + type: "plain_text", 987 + text: "🎬 Start New Session", 988 + emoji: true, 989 + }, 990 + value: "start", 991 + action_id: "takes_start", 992 + }, 993 + { 994 + type: "button", 995 + text: { 996 + type: "plain_text", 997 + text: "👁️ Status", 998 + emoji: true, 999 + }, 1000 + value: "status", 1001 + action_id: "takes_status", 1002 + }, 1003 + { 1004 + type: "button", 1005 + text: { 1006 + type: "plain_text", 1007 + text: "🔄 Refresh", 1008 + emoji: true, 1009 + }, 1010 + value: "status", 1011 + action_id: "takes_history", 1012 + }, 1013 + ], 1014 + }); 1015 + 1016 + return { 1017 + text: `Your recent takes history (${completedTakes.length} sessions)`, 1018 + response_type: "ephemeral", 1019 + blocks: historyBlocks, 1020 + }; 1021 + }; 1022 + 492 1023 const handleHelp = async (): Promise<MessageResponse> => { 493 1024 return { 494 - text: `*Takes Commands*\n\n• \`/takes start\` - Start a new takes session\n• \`/takes pause\` - Pause your current session\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop\` - End your current session\n• \`/takes status\` - Check the status of your session`, 1025 + text: `*Takes Commands*\n\n• \`/takes start [minutes]\` - Start a new takes session, optionally specifying duration\n• \`/takes pause\` - Pause your current session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End your current session with optional notes\n• \`/takes status\` - Check the status of your session\n• \`/takes history\` - View your past takes sessions`, 495 1026 response_type: "ephemeral", 496 1027 blocks: [ 497 1028 { ··· 505 1036 type: "section", 506 1037 text: { 507 1038 type: "mrkdwn", 508 - text: "• `/takes start` - Start a new takes session\n• `/takes pause` - Pause your current session\n• `/takes resume` - Resume your paused session\n• `/takes stop` - End your current session\n• `/takes status` - Check the status of your session", 1039 + text: `• \`/takes start [minutes]\` - Start a new session (default: ${TakesConfig.DEFAULT_SESSION_LENGTH} min)\n• \`/takes pause\` - Pause your session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End session with optional notes\n• \`/takes status\` - Check status\n• \`/takes history\` - View past sessions`, 509 1040 }, 510 1041 }, 511 1042 { ··· 520 1051 }, 521 1052 value: "start", 522 1053 action_id: "takes_start", 1054 + }, 1055 + { 1056 + type: "button", 1057 + text: { 1058 + type: "plain_text", 1059 + text: "📋 History", 1060 + emoji: true, 1061 + }, 1062 + value: "history", 1063 + action_id: "takes_history", 523 1064 }, 524 1065 ], 525 1066 }, 526 1067 ], 527 1068 }; 528 1069 }; 1070 + const getDescriptionBlocks = (error?: string): MessageResponse => { 1071 + const blocks: AnyMessageBlock[] = [ 1072 + { 1073 + type: "input", 1074 + block_id: "note_block", 1075 + element: { 1076 + type: "plain_text_input", 1077 + action_id: "note_input", 1078 + placeholder: { 1079 + type: "plain_text", 1080 + text: "Enter a note for your session", 1081 + }, 1082 + multiline: true, 1083 + }, 1084 + label: { 1085 + type: "plain_text", 1086 + text: "Note", 1087 + }, 1088 + }, 1089 + { 1090 + type: "actions", 1091 + elements: [ 1092 + { 1093 + type: "button", 1094 + text: { 1095 + type: "plain_text", 1096 + text: "🎬 Start Session", 1097 + emoji: true, 1098 + }, 1099 + value: "start", 1100 + action_id: "takes_start", 1101 + }, 1102 + { 1103 + type: "button", 1104 + text: { 1105 + type: "plain_text", 1106 + text: "⛔ Cancel", 1107 + emoji: true, 1108 + }, 1109 + value: "cancel", 1110 + action_id: "takes_status", 1111 + style: "danger", 1112 + }, 1113 + ], 1114 + }, 1115 + ]; 1116 + 1117 + if (error) { 1118 + blocks.push( 1119 + { 1120 + type: "divider", 1121 + }, 1122 + { 1123 + type: "context", 1124 + elements: [ 1125 + { 1126 + type: "mrkdwn", 1127 + text: `⚠️ ${error}`, 1128 + }, 1129 + ], 1130 + }, 1131 + ); 1132 + } 1133 + 1134 + return { 1135 + text: "Please enter a note for your session:", 1136 + response_type: "ephemeral", 1137 + blocks, 1138 + }; 1139 + }; 1140 + 1141 + const getEditDescriptionBlocks = ( 1142 + description: string, 1143 + error?: string, 1144 + ): MessageResponse => { 1145 + const blocks: AnyMessageBlock[] = [ 1146 + { 1147 + type: "input", 1148 + block_id: "note_block", 1149 + element: { 1150 + type: "plain_text_input", 1151 + action_id: "note_input", 1152 + placeholder: { 1153 + type: "plain_text", 1154 + text: "Enter a note for your session", 1155 + }, 1156 + multiline: true, 1157 + initial_value: description, 1158 + }, 1159 + label: { 1160 + type: "plain_text", 1161 + text: "Note", 1162 + }, 1163 + }, 1164 + { 1165 + type: "actions", 1166 + elements: [ 1167 + { 1168 + type: "button", 1169 + text: { 1170 + type: "plain_text", 1171 + text: "✍️ Update Note", 1172 + emoji: true, 1173 + }, 1174 + value: "start", 1175 + action_id: "takes_edit", 1176 + }, 1177 + { 1178 + type: "button", 1179 + text: { 1180 + type: "plain_text", 1181 + text: "⛔ Cancel", 1182 + emoji: true, 1183 + }, 1184 + value: "cancel", 1185 + action_id: "takes_status", 1186 + style: "danger", 1187 + }, 1188 + ], 1189 + }, 1190 + ]; 1191 + 1192 + if (error) { 1193 + blocks.push( 1194 + { 1195 + type: "divider", 1196 + }, 1197 + { 1198 + type: "context", 1199 + elements: [ 1200 + { 1201 + type: "mrkdwn", 1202 + text: `⚠️ ${error}`, 1203 + }, 1204 + ], 1205 + }, 1206 + ); 1207 + } 1208 + 1209 + return { 1210 + text: "Please enter a note for your session:", 1211 + response_type: "ephemeral", 1212 + blocks, 1213 + }; 1214 + }; 529 1215 530 1216 // Main command handler 531 1217 slackApp.command("/takes", async ({ payload, context }): Promise<void> => { ··· 542 1228 const pausedTakeCheck = 543 1229 activeTake.length === 0 ? await getPausedTake(userId) : []; 544 1230 1231 + // Run checks for expired or about-to-expire sessions 1232 + await expirePausedSessions(); 1233 + await checkActiveSessions(); 1234 + 545 1235 // Default to status if we have an active or paused session and no command specified 546 1236 if ( 547 1237 subcommand === "" && ··· 554 1244 555 1245 let response: MessageResponse | undefined; 556 1246 1247 + // Special handling for start command to show modal 1248 + if (subcommand === "start" && !activeTake.length) { 1249 + response = getDescriptionBlocks(); 1250 + } 1251 + 557 1252 // Route to the appropriate handler function 558 1253 switch (subcommand) { 559 1254 case "start": ··· 566 1261 response = await handleResume(userId); 567 1262 break; 568 1263 case "stop": 569 - response = await handleStop(userId); 1264 + response = await handleStop(userId, args); 1265 + break; 1266 + case "edit": 1267 + response = getEditDescriptionBlocks( 1268 + activeTake[0]?.description || "", 1269 + ); 570 1270 break; 571 1271 case "status": 572 1272 response = await handleStatus(userId); 1273 + break; 1274 + case "history": 1275 + response = await handleHistory(userId); 1276 + break; 1277 + case "help": 1278 + response = await handleHelp(); 573 1279 break; 574 1280 default: 575 - case "help": 576 1281 response = await handleHelp(); 577 1282 break; 578 1283 } ··· 587 1292 }); 588 1293 589 1294 // Handle button actions 590 - slackApp.action(/^takes_(\w+)$/, async ({ body, context }) => { 591 - const userId = body.user.id; 592 - const channelId = body.channel?.id || ""; 593 - const actionId = body.actions[0].action_id; 1295 + slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => { 1296 + const userId = payload.user.id; 1297 + const channelId = context.channelId || ""; 1298 + const actionId = payload.actions[0]?.action_id as string; 594 1299 const command = actionId.replace("takes_", ""); 1300 + const descriptionInput = payload.state.values.note_block?.note_input; 595 1301 596 1302 let response: MessageResponse | undefined; 597 1303 1304 + const activeTake = await getActiveTake(userId); 1305 + 598 1306 // Route to the appropriate handler function 599 1307 switch (command) { 600 - case "start": 601 - response = await handleStart(userId, channelId); 1308 + case "start": { 1309 + if (activeTake.length > 0) { 1310 + if (context.respond) { 1311 + response = await handleStatus(userId); 1312 + } 1313 + } else { 1314 + if (!descriptionInput?.value?.trim()) { 1315 + response = getDescriptionBlocks( 1316 + "Please enter a note for your session.", 1317 + ); 1318 + } else { 1319 + response = await handleStart( 1320 + userId, 1321 + channelId, 1322 + descriptionInput?.value?.trim(), 1323 + ); 1324 + } 1325 + } 602 1326 break; 1327 + } 603 1328 case "pause": 604 1329 response = await handlePause(userId); 605 1330 break; ··· 609 1334 case "stop": 610 1335 response = await handleStop(userId); 611 1336 break; 1337 + case "edit": { 1338 + if (!activeTake.length && context.respond) { 1339 + await context.respond({ 1340 + text: "You don't have an active takes session to edit!", 1341 + response_type: "ephemeral", 1342 + }); 1343 + return; 1344 + } 1345 + 1346 + if (!descriptionInput) { 1347 + response = getEditDescriptionBlocks( 1348 + activeTake[0]?.description || "", 1349 + ); 1350 + } else if (descriptionInput.value?.trim()) { 1351 + const takeToUpdate = activeTake[0]; 1352 + if (!takeToUpdate) return; 1353 + 1354 + // Update the note for the active session 1355 + await db.update(takesTable).set({ 1356 + description: descriptionInput.value.trim(), 1357 + }); 1358 + 1359 + response = await handleStatus(userId); 1360 + } else { 1361 + response = getEditDescriptionBlocks( 1362 + "", 1363 + "Please enter a note for your session.", 1364 + ); 1365 + } 1366 + break; 1367 + } 1368 + 612 1369 case "status": 613 1370 response = await handleStatus(userId); 614 1371 break; 1372 + case "history": 1373 + response = await handleHistory(userId); 1374 + break; 615 1375 default: 616 1376 response = await handleHelp(); 617 1377 break; 618 1378 } 619 1379 620 - if (context.respond) 621 - await context.respond( 622 - response || { 623 - text: "An error occurred while processing your request.", 624 - }, 625 - ); 1380 + // Send the response 1381 + if (response && context.respond) { 1382 + await context.respond(response); 1383 + } 626 1384 }); 1385 + 1386 + // Setup scheduled tasks 1387 + const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL; 1388 + setInterval(async () => { 1389 + await checkActiveSessions(); 1390 + await expirePausedSessions(); 1391 + }, notificationInterval); 627 1392 }; 628 1393 629 1394 export default takes;
+32
src/libs/config.ts
··· 1 + // Configuration defaults and constants for the takes application 2 + 3 + export const TakesConfig = { 4 + // Default takes session length in minutes (should be 90 for production) 5 + DEFAULT_SESSION_LENGTH: 2, 6 + 7 + // Maximum time in minutes that a takes session can be paused before automatic expiration 8 + MAX_PAUSE_DURATION: 3, 9 + 10 + // Maximum number of past takes to display in history 11 + MAX_HISTORY_ITEMS: 5, 12 + 13 + // Time thresholds for notifications (in minutes) 14 + NOTIFICATIONS: { 15 + // When to send a warning about low time remaining (minutes) 16 + LOW_TIME_WARNING: 2, 17 + 18 + // When to send a warning about pause expiration (minutes) 19 + PAUSE_EXPIRATION_WARNING: 5, 20 + 21 + // Frequency to check for notifications (milliseconds) 22 + CHECK_INTERVAL: 5 * 1000, // Every minute 23 + }, 24 + 25 + // Modal settings 26 + MODAL: { 27 + // Maximum length for take description 28 + MAX_DESCRIPTION_LENGTH: 100, 29 + }, 30 + }; 31 + 32 + export default TakesConfig;
+21 -14
src/libs/schema.ts
··· 2 2 3 3 // Define the takes table 4 4 export const takes = sqliteTable("takes", { 5 - id: text("id").primaryKey(), 6 - userId: text("user_id").notNull(), 7 - channelId: text("channel_id").notNull(), 8 - status: text("status").notNull().default("active"), // active, paused, completed 9 - startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 10 - pausedAt: integer("paused_at", { mode: "timestamp" }), 11 - completedAt: integer("completed_at", { mode: "timestamp" }), 12 - durationMinutes: integer("duration_minutes").notNull().default(5), // 5 minutes for testing (should be 90) 13 - pausedTimeMs: integer("paused_time_ms").notNull().default(0), // cumulative paused time 14 - notes: text("notes"), 5 + id: text("id").primaryKey(), 6 + userId: text("user_id").notNull(), 7 + channelId: text("channel_id").notNull(), 8 + status: text("status").notNull().default("active"), // active, paused, completed 9 + startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 10 + pausedAt: integer("paused_at", { mode: "timestamp" }), 11 + completedAt: integer("completed_at", { mode: "timestamp" }), 12 + durationMinutes: integer("duration_minutes").notNull().default(5), // 5 minutes for testing (should be 90) 13 + pausedTimeMs: integer("paused_time_ms").notNull().default(0), // cumulative paused time 14 + notes: text("notes"), 15 + description: text("description"), 16 + notifiedLowTime: integer("notified_low_time", { mode: "boolean" }).default( 17 + false, 18 + ), // has user been notified about low time 19 + notifiedPauseExpiration: integer("notified_pause_expiration", { 20 + mode: "boolean", 21 + }).default(false), // has user been notified about pause expiration 15 22 }); 16 23 17 24 // Define the users table 18 25 export const users = sqliteTable("users", { 19 - id: text("id").primaryKey(), 20 - name: text("name").notNull(), 21 - isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), 22 - }); 26 + id: text("id").primaryKey(), 27 + name: text("name").notNull(), 28 + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), 29 + });