This repository has no description
0

Configure Feed

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

feat: move to periods time system

+325 -180
+7
src/features/api/routes/video.ts
··· 4 4 5 5 export default async function getVideo(url: URL): Promise<Response> { 6 6 const videoId = url.pathname.split("/")[2]; 7 + const thumbnail = url.pathname.split("/")[3] === "thumbnail"; 7 8 8 9 if (!videoId) { 9 10 return new Response("Invalid video id", { status: 400 }); ··· 19 20 } 20 21 21 22 const videoData = video[0]; 23 + 24 + if (thumbnail) { 25 + return Response.redirect( 26 + `https://cachet.dunkirk.sh/users/${videoData?.userId}/r`, 27 + ); 28 + } 22 29 23 30 return new Response( 24 31 `<!DOCTYPE html>
+5 -17
src/features/takes/handlers/history.ts
··· 2 2 import TakesConfig from "../../../libs/config"; 3 3 import { getCompletedTakes } from "../services/database"; 4 4 import type { MessageResponse } from "../types"; 5 + import { calculateElapsedTime } from "../../../libs/time-periods"; 6 + import { prettyPrintTime } from "../../../libs/time"; 5 7 6 8 export async function handleHistory(userId: string): Promise<MessageResponse> { 7 9 // Get completed takes for the user ··· 25 27 type: "header", 26 28 text: { 27 29 type: "plain_text", 28 - text: `📋 Your most recent ${completedTakes.length} Takes Sessions`, 30 + text: `📋 Your most recent ${completedTakes.length} Takes sessions`, 29 31 emoji: true, 30 32 }, 31 33 }, 32 34 ]; 33 35 34 36 for (const take of completedTakes) { 35 - const startTime = new Date(take.startedAt); 36 - const endTime = take.completedAt || startTime; 37 - 38 - // Calculate duration in minutes 39 - const durationMs = endTime.getTime() - startTime.getTime(); 40 - const pausedMs = take.pausedTimeMs || 0; 41 - const activeDuration = Math.round((durationMs - pausedMs) / 60000); 42 - 43 - // Format dates 44 - const startDate = `<!date^${Math.floor(startTime.getTime() / 1000)}^{date_short_pretty} at {time}|${startTime.toLocaleString()}>`; 45 - const endDate = `<!date^${Math.floor(endTime.getTime() / 1000)}^{date_short_pretty} at {time}|${endTime.toLocaleString()}>`; 37 + const elapsedTime = calculateElapsedTime(JSON.parse(take.periods)); 46 38 47 39 const notes = take.notes ? `\n• Notes: ${take.notes}` : ""; 48 40 const description = take.description ··· 53 45 type: "section", 54 46 text: { 55 47 type: "mrkdwn", 56 - text: `*Take on ${startDate}*\n${description}• Duration: ${activeDuration} minutes${ 57 - pausedMs > 0 58 - ? ` (+ ${Math.round(pausedMs / 60000)} minutes paused)` 59 - : "" 60 - }\n• Started: ${startDate}\n• Completed: ${endDate}${notes}`, 48 + text: `*Duration:* \`${prettyPrintTime(elapsedTime)}\`\n*Status:* ${take.status}\n${notes ? `*Notes:* ${take.notes}\n` : ""}${description ? `*Description:* ${take.description}\n` : ""}`, 61 49 }, 62 50 }); 63 51
+22 -12
src/features/takes/handlers/pause.ts
··· 4 4 import TakesConfig from "../../../libs/config"; 5 5 import { getActiveTake } from "../services/database"; 6 6 import type { MessageResponse } from "../types"; 7 - import { prettyPrintTime } from "../../../libs/time"; 7 + import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 8 + import { 9 + addNewPeriod, 10 + getPausedTimeRemaining, 11 + } from "../../../libs/time-periods"; 8 12 9 13 export default async function handlePause( 10 14 userId: string, ··· 22 26 return; 23 27 } 24 28 29 + const newPeriods = JSON.stringify( 30 + addNewPeriod(takeToUpdate.periods, "paused"), 31 + ); 32 + 33 + const pausedTime = getPausedTimeRemaining(newPeriods); 34 + 35 + if (pausedTime > TakesConfig.MAX_PAUSE_DURATION) { 36 + return { 37 + text: `You can't pause for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes!`, 38 + response_type: "ephemeral", 39 + }; 40 + } 41 + 25 42 // Update the takes entry to paused status 26 43 await db 27 44 .update(takesTable) 28 45 .set({ 29 46 status: "paused", 30 - pausedAt: new Date(), 47 + periods: newPeriods, 31 48 notifiedPauseExpiration: false, // Reset pause expiration notification 32 49 }) 33 50 .where(eq(takesTable.id, takeToUpdate.id)); 34 51 35 - // Calculate when the pause will expire 36 - const pauseExpires = new Date(); 37 - pauseExpires.setMinutes( 38 - pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION, 39 - ); 40 - const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`; 41 - 42 52 return { 43 - 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.`, 53 + text: `⏸️ Session paused! You have ${prettyPrintTime(TakesConfig.MAX_PAUSE_DURATION * 60000 - pausedTime)} remaining. It will automatically finish at ${generateSlackDate(new Date(Date.now() + TakesConfig.MAX_PAUSE_DURATION * 60000))}`, 44 54 response_type: "ephemeral", 45 55 blocks: [ 46 56 { 47 57 type: "section", 48 58 text: { 49 59 type: "mrkdwn", 50 - text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining.`, 60 + text: `⏸️ Session paused! You have ${prettyPrintTime(TakesConfig.MAX_PAUSE_DURATION * 60000 - pausedTime)} remaining.`, 51 61 }, 52 62 }, 53 63 { ··· 58 68 elements: [ 59 69 { 60 70 type: "mrkdwn", 61 - text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`, 71 + text: `It will automatically finish at ${generateSlackDate(new Date(Date.now() + TakesConfig.MAX_PAUSE_DURATION * 60000))} if not resumed.`, 62 72 }, 63 73 ], 64 74 },
+19 -25
src/features/takes/handlers/resume.ts
··· 4 4 import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 5 5 import { getPausedTake } from "../services/database"; 6 6 import type { MessageResponse } from "../types"; 7 + import { addNewPeriod, getRemainingTime } from "../../../libs/time-periods"; 7 8 8 9 export default async function handleResume( 9 10 userId: string, ··· 22 23 } 23 24 24 25 const now = new Date(); 26 + const newPeriods = JSON.stringify( 27 + addNewPeriod(pausedSession.periods, "active"), 28 + ); 25 29 26 - // Calculate paused time 27 - if (pausedSession.pausedAt) { 28 - const pausedTimeMs = now.getTime() - pausedSession.pausedAt.getTime(); 29 - const totalPausedTime = 30 - (pausedSession.pausedTimeMs || 0) + pausedTimeMs; 31 - 32 - // Update the takes entry to active status 33 - await db 34 - .update(takesTable) 35 - .set({ 36 - status: "active", 37 - pausedAt: null, 38 - pausedTimeMs: totalPausedTime, 39 - notifiedLowTime: false, // Reset low time notification 40 - }) 41 - .where(eq(takesTable.id, pausedSession.id)); 42 - } 30 + // Update the takes entry to active status 31 + await db 32 + .update(takesTable) 33 + .set({ 34 + status: "active", 35 + lastResumeAt: now, 36 + periods: newPeriods, 37 + notifiedLowTime: false, // Reset low time notification 38 + }) 39 + .where(eq(takesTable.id, pausedSession.id)); 43 40 44 - const endTime = new Date( 45 - new Date(pausedSession.startedAt).getTime() + 46 - pausedSession.durationMinutes * 60000 + 47 - (pausedSession.pausedTimeMs || 0), 41 + const endTime = getRemainingTime( 42 + pausedSession.targetDurationMs, 43 + pausedSession.periods, 48 44 ); 49 45 50 - const timeRemaining = endTime.getTime() - now.getTime(); 51 - 52 46 return { 53 - text: `▶️ Takes session resumed! You have ${prettyPrintTime(timeRemaining)} remaining in your session.`, 47 + text: `▶️ Takes session resumed! You have ${prettyPrintTime(endTime.remaining)} remaining in your session.`, 54 48 response_type: "ephemeral", 55 49 blocks: [ 56 50 { ··· 68 62 elements: [ 69 63 { 70 64 type: "mrkdwn", 71 - text: `You have ${prettyPrintTime(timeRemaining)} remaining until ${generateSlackDate(endTime)}.`, 65 + text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`, 72 66 }, 73 67 ], 74 68 },
+15 -8
src/features/takes/handlers/start.ts
··· 4 4 import { takes as takesTable } from "../../../libs/schema"; 5 5 import TakesConfig from "../../../libs/config"; 6 6 import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 7 + import { getRemainingTime } from "../../../libs/time-periods"; 7 8 8 9 export default async function handleStart( 9 10 userId: string, 10 11 channelId: string, 11 12 description?: string, 12 - durationMinutes?: number, 13 13 ): Promise<MessageResponse> { 14 14 const activeTake = await getActiveTake(userId); 15 15 if (activeTake.length > 0) { ··· 23 23 const newTake = { 24 24 id: Bun.randomUUIDv7(), 25 25 userId, 26 - channelId, 27 26 status: "active", 28 - startedAt: new Date(), 29 - durationMinutes: durationMinutes || TakesConfig.DEFAULT_SESSION_LENGTH, 27 + targetDurationMs: TakesConfig.DEFAULT_SESSION_LENGTH * 60000, 28 + periods: JSON.stringify([ 29 + { 30 + type: "active", 31 + startTime: Date.now(), 32 + endTime: null, 33 + }, 34 + ]), 35 + elapsedTimeMs: 0, 30 36 description: description || null, 31 37 notifiedLowTime: false, 32 38 notifiedPauseExpiration: false, ··· 35 41 await db.insert(takesTable).values(newTake); 36 42 37 43 // Calculate end time for message 38 - const endTime = new Date( 39 - newTake.startedAt.getTime() + newTake.durationMinutes * 60000, 44 + const endTime = getRemainingTime( 45 + TakesConfig.DEFAULT_SESSION_LENGTH * 60000, 46 + newTake.periods, 40 47 ); 41 48 42 49 const descriptionText = description 43 50 ? `\n\n*Working on:* ${description}` 44 51 : ""; 45 52 return { 46 - text: `🎬 Takes session started! You have ${prettyPrintTime(newTake.durationMinutes * 60000)} until ${generateSlackDate(endTime)}.${descriptionText}`, 53 + text: `🎬 Takes session started! You have ${prettyPrintTime(endTime.remaining)} until ${generateSlackDate(endTime.endTime)}.${descriptionText}`, 47 54 response_type: "ephemeral", 48 55 blocks: [ 49 56 { ··· 61 68 elements: [ 62 69 { 63 70 type: "mrkdwn", 64 - text: `You have ${prettyPrintTime(newTake.durationMinutes * 60000)} left until ${generateSlackDate(endTime)}.`, 71 + text: `You have ${prettyPrintTime(endTime.remaining)} left until ${generateSlackDate(endTime.endTime)}.`, 65 72 }, 66 73 ], 67 74 },
+17 -34
src/features/takes/handlers/status.ts
··· 1 1 import TakesConfig from "../../../libs/config"; 2 2 import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 3 3 import { 4 + getPausedTimeRemaining, 5 + getRemainingTime, 6 + } from "../../../libs/time-periods"; 7 + import { 4 8 getActiveTake, 5 9 getCompletedTakes, 6 10 getPausedTake, ··· 22 26 return; 23 27 } 24 28 25 - const startTime = new Date(take.startedAt); 26 - const endTime = new Date( 27 - startTime.getTime() + take.durationMinutes * 60000, 28 - ); 29 - 30 - // Adjust for paused time 31 - if (take.pausedTimeMs) { 32 - endTime.setTime(endTime.getTime() + take.pausedTimeMs); 33 - } 34 - 35 - const now = new Date(); 36 - const remainingMs = endTime.getTime() - now.getTime(); 29 + const endTime = getRemainingTime(take.targetDurationMs, take.periods); 37 30 38 31 // Add description to display if present 39 32 const descriptionText = take.description ··· 41 34 : ""; 42 35 43 36 return { 44 - text: `🎬 You have an active takes session with ${prettyPrintTime(remainingMs)} remaining.${descriptionText}`, 37 + text: `🎬 You have an active takes session with ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`, 45 38 response_type: "ephemeral", 46 39 blocks: [ 47 40 { ··· 59 52 elements: [ 60 53 { 61 54 type: "mrkdwn", 62 - text: `You have ${prettyPrintTime(remainingMs)} remaining until ${generateSlackDate(endTime)}.`, 55 + text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`, 63 56 }, 64 57 ], 65 58 }, ··· 119 112 120 113 if (pausedTakeStatus.length > 0) { 121 114 const pausedTake = pausedTakeStatus[0]; 122 - if (!pausedTake || !pausedTake.pausedAt) { 115 + if (!pausedTake) { 123 116 return; 124 117 } 125 118 126 119 // Calculate how much time remains before auto-completion 127 - const now = new Date(); 128 - const pausedDuration = 129 - (now.getTime() - pausedTake.pausedAt.getTime()) / (60 * 1000); // In minutes 130 - const remainingPauseTime = Math.max( 131 - 0, 132 - TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 120 + const endTime = getRemainingTime( 121 + pausedTake.targetDurationMs, 122 + pausedTake.periods, 133 123 ); 134 - 135 - // Format the pause timeout 136 - const pauseExpires = new Date(pausedTake.pausedAt); 137 - pauseExpires.setMinutes( 138 - pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION, 139 - ); 140 - const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`; 124 + const pauseExpires = getPausedTimeRemaining(pausedTake.periods); 141 125 142 126 // Add notes to display if present 143 127 const noteText = pausedTake.notes ··· 145 129 : ""; 146 130 147 131 return { 148 - text: `⏸️ You have a paused takes session. It will auto-complete in ${remainingPauseTime.toFixed(1)} minutes if not resumed.`, 132 + text: `⏸️ You have a paused takes session. It will auto-complete in ${prettyPrintTime(pauseExpires)} if not resumed.`, 149 133 response_type: "ephemeral", 150 134 blocks: [ 151 135 { 152 136 type: "section", 153 137 text: { 154 138 type: "mrkdwn", 155 - text: `⏸️ Session paused! You have ${prettyPrintTime(pausedTake.durationMinutes * 60000)} remaining.`, 139 + text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.`, 156 140 }, 157 141 }, 158 142 { ··· 163 147 elements: [ 164 148 { 165 149 type: "mrkdwn", 166 - text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`, 150 + text: `It will automatically finish in ${prettyPrintTime(pauseExpires)} (by ${generateSlackDate(new Date(new Date().getTime() - pauseExpires))}) if not resumed.`, 167 151 }, 168 152 ], 169 153 }, ··· 214 198 const diffMs = 215 199 new Date().getTime() - 216 200 // @ts-expect-error - TS doesn't know that we are checking the length 217 - completedSessions[ 218 - completedSessions.length - 1 219 - ].startedAt.getTime(); 201 + completedSessions[completedSessions.length - 1] 202 + ?.completedAt; 220 203 221 204 const hours = Math.ceil(diffMs / (1000 * 60 * 60)); 222 205 if (hours < 24) return `${hours} hours`;
+55
src/features/takes/handlers/stop.ts
··· 4 4 import { eq } from "drizzle-orm"; 5 5 import { getActiveTake, getPausedTake } from "../services/database"; 6 6 import type { MessageResponse } from "../types"; 7 + import { prettyPrintTime } from "../../../libs/time"; 8 + import { 9 + calculateElapsedTime, 10 + getRemainingTime, 11 + } from "../../../libs/time-periods"; 7 12 8 13 export default async function handleStop( 9 14 userId: string, ··· 33 38 notes = args.slice(1).join(" "); 34 39 } 35 40 41 + const elapsed = calculateElapsedTime( 42 + JSON.parse(pausedTakeToStop.periods), 43 + ); 44 + 36 45 const res = await slackClient.chat.postMessage({ 37 46 channel: userId, 38 47 text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 48 + blocks: [ 49 + { 50 + type: "section", 51 + text: { 52 + type: "mrkdwn", 53 + text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 54 + }, 55 + }, 56 + { 57 + type: "divider", 58 + }, 59 + { 60 + type: "context", 61 + elements: [ 62 + { 63 + type: "mrkdwn", 64 + text: `*Duration:* ${prettyPrintTime(elapsed)} ${notes ? `\n*Notes:* ${notes}` : ""}`, 65 + }, 66 + ], 67 + }, 68 + ], 39 69 }); 40 70 41 71 await db ··· 60 90 notes = args.slice(1).join(" "); 61 91 } 62 92 93 + const elapsed = calculateElapsedTime( 94 + JSON.parse(activeTakeToStop.periods), 95 + ); 96 + 63 97 const res = await slackClient.chat.postMessage({ 64 98 channel: userId, 65 99 text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 100 + blocks: [ 101 + { 102 + type: "section", 103 + text: { 104 + type: "mrkdwn", 105 + text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 106 + }, 107 + }, 108 + { 109 + type: "divider", 110 + }, 111 + { 112 + type: "context", 113 + elements: [ 114 + { 115 + type: "mrkdwn", 116 + text: `*Duration:* ${prettyPrintTime(elapsed)} ${notes ? `\n*Notes:* ${notes}` : ""}`, 117 + }, 118 + ], 119 + }, 120 + ], 66 121 }); 67 122 68 123 await db
+5 -2
src/features/takes/services/database.ts
··· 1 1 import { db } from "../../../libs/db"; 2 2 import { takes as takesTable } from "../../../libs/schema"; 3 - import { eq, and, desc } from "drizzle-orm"; 3 + import { eq, and, desc, not } from "drizzle-orm"; 4 4 5 5 export async function getActiveTake(userId: string) { 6 6 return db ··· 29 29 .where( 30 30 and( 31 31 eq(takesTable.userId, userId), 32 - eq(takesTable.status, "completed"), 32 + and( 33 + not(eq(takesTable.status, "active")), 34 + not(eq(takesTable.status, "paused")), 35 + ), 33 36 ), 34 37 ) 35 38 .orderBy(desc(takesTable.completedAt))
+64 -68
src/features/takes/services/notifications.ts
··· 3 3 import { db } from "../../../libs/db"; 4 4 import { takes as takesTable } from "../../../libs/schema"; 5 5 import { eq } from "drizzle-orm"; 6 + import { 7 + getPausedDuration, 8 + getRemainingTime, 9 + } from "../../../libs/time-periods"; 6 10 7 11 // Check for paused sessions that have exceeded the max pause duration 8 12 export async function expirePausedSessions() { ··· 13 17 .where(eq(takesTable.status, "paused")); 14 18 15 19 for (const take of pausedTakes) { 16 - if (take.pausedAt) { 17 - const pausedDuration = 18 - (now.getTime() - take.pausedAt.getTime()) / (60 * 1000); // Convert to minutes 20 + const pausedDuration = getPausedDuration(take.periods) / 60000; // Convert to minutes 19 21 20 - // Send warning notification when getting close to expiration 21 - if ( 22 - pausedDuration > 23 - TakesConfig.MAX_PAUSE_DURATION - 24 - TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING && 25 - !take.notifiedPauseExpiration 26 - ) { 27 - // Update notification flag 28 - await db 29 - .update(takesTable) 30 - .set({ 31 - notifiedPauseExpiration: true, 32 - }) 33 - .where(eq(takesTable.id, take.id)); 22 + // Send warning notification when getting close to expiration 23 + if ( 24 + pausedDuration > 25 + TakesConfig.MAX_PAUSE_DURATION - 26 + TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING && 27 + !take.notifiedPauseExpiration 28 + ) { 29 + // Update notification flag 30 + await db 31 + .update(takesTable) 32 + .set({ 33 + notifiedPauseExpiration: true, 34 + }) 35 + .where(eq(takesTable.id, take.id)); 34 36 35 - // Send warning message 36 - try { 37 - const timeRemaining = Math.round( 38 - TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 39 - ); 40 - await slackApp.client.chat.postMessage({ 41 - channel: take.userId, 42 - text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`, 43 - }); 44 - } catch (error) { 45 - console.error( 46 - "Failed to send pause expiration warning:", 47 - error, 48 - ); 49 - } 37 + // Send warning message 38 + try { 39 + const timeRemaining = Math.round( 40 + TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 41 + ); 42 + await slackApp.client.chat.postMessage({ 43 + channel: take.userId, 44 + text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`, 45 + }); 46 + } catch (error) { 47 + console.error( 48 + "Failed to send pause expiration warning:", 49 + error, 50 + ); 50 51 } 51 - 52 - // Auto-expire paused sessions that exceed the max pause duration 53 - if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) { 54 - let ts: string | undefined; 55 - // Notify user that their session was auto-completed 56 - try { 57 - const res = await slackApp.client.chat.postMessage({ 58 - channel: take.userId, 59 - text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`, 60 - }); 61 - ts = res.ts; 62 - } catch (error) { 63 - console.error( 64 - "Failed to notify user of auto-completed session:", 65 - error, 66 - ); 67 - } 52 + } 68 53 69 - await db 70 - .update(takesTable) 71 - .set({ 72 - status: "waitingUpload", 73 - completedAt: now, 74 - ts, 75 - notes: take.notes 76 - ? `${take.notes} (Automatically completed due to pause timeout)` 77 - : "Automatically completed due to pause timeout", 78 - }) 79 - .where(eq(takesTable.id, take.id)); 54 + // Auto-expire paused sessions that exceed the max pause duration 55 + if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) { 56 + let ts: string | undefined; 57 + // Notify user that their session was auto-completed 58 + try { 59 + const res = await slackApp.client.chat.postMessage({ 60 + channel: take.userId, 61 + text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`, 62 + }); 63 + ts = res.ts; 64 + } catch (error) { 65 + console.error( 66 + "Failed to notify user of auto-completed session:", 67 + error, 68 + ); 80 69 } 70 + 71 + await db 72 + .update(takesTable) 73 + .set({ 74 + status: "waitingUpload", 75 + completedAt: now, 76 + ts, 77 + notes: take.notes 78 + ? `${take.notes} (Automatically completed due to pause timeout)` 79 + : "Automatically completed due to pause timeout", 80 + }) 81 + .where(eq(takesTable.id, take.id)); 81 82 } 82 83 } 83 84 } ··· 91 92 .where(eq(takesTable.status, "active")); 92 93 93 94 for (const take of activeTakes) { 94 - const endTime = new Date( 95 - take.startedAt.getTime() + 96 - take.durationMinutes * 60000 + 97 - (take.pausedTimeMs || 0), 98 - ); 95 + const endTime = getRemainingTime(take.targetDurationMs, take.periods); 99 96 100 - const remainingMs = endTime.getTime() - now.getTime(); 101 - const remainingMinutes = remainingMs / 60000; 97 + const remainingMinutes = endTime.remaining / 60000; 102 98 103 99 if ( 104 100 remainingMinutes <= TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING && ··· 122 118 } 123 119 } 124 120 125 - if (remainingMs <= 0) { 121 + if (endTime.remaining <= 0) { 126 122 let ts: string | undefined; 127 123 try { 128 124 const res = await slackApp.client.chat.postMessage({
+17 -9
src/features/takes/services/upload.ts
··· 3 3 import { takes as takesTable } from "../../../libs/schema"; 4 4 import { eq, and } from "drizzle-orm"; 5 5 import { prettyPrintTime } from "../../../libs/time"; 6 + import { calculateElapsedTime } from "../../../libs/time-periods"; 6 7 7 8 export default async function upload() { 8 9 slackApp.anyMessage(async ({ payload }) => { ··· 58 59 const match = html.match(/src="([^"]*\.mp4[^"]*)"/); 59 60 const takePublicUrl = match?.[1]; 60 61 62 + const takeUploadedAt = new Date(); 63 + 61 64 await db 62 65 .update(takesTable) 63 66 .set({ 64 67 status: "uploaded", 65 - takeUploadedAt: new Date(), 68 + takeUploadedAt, 66 69 takeUrl: takePublicUrl, 67 - takeThumbUrl: file?.thumb_video, 68 70 }) 69 71 .where(eq(takesTable.id, take.id)); 70 72 ··· 74 76 name: "fire", 75 77 }); 76 78 79 + const takeDuration = calculateElapsedTime(JSON.parse(take.periods)); 80 + 77 81 await slackClient.chat.postMessage({ 78 82 channel: payload.channel, 79 83 thread_ts: payload.thread_ts, ··· 94 98 elements: [ 95 99 { 96 100 type: "mrkdwn", 97 - text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 101 + text: `take by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`, 98 102 }, 99 103 ], 100 104 }, ··· 103 107 104 108 await slackClient.chat.postMessage({ 105 109 channel: process.env.SLACK_REVIEW_CHANNEL || "", 106 - text: "", 110 + text: ":video_camera: new take uploaded!", 107 111 blocks: [ 108 112 { 109 113 type: "section", 110 114 text: { 111 115 type: "mrkdwn", 112 - text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 116 + text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`, 113 117 }, 114 118 }, 115 119 { ··· 121 125 title_url: `${process.env.API_URL}/video/${take.id}`, 122 126 title: { 123 127 type: "plain_text", 124 - text: `take on ${take.takeUploadedAt?.toISOString()}`, 128 + text: `takes from ${takeUploadedAt?.toISOString()}`, 125 129 }, 126 130 thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`, 127 - alt_text: `take on ${take.takeUploadedAt?.toISOString()}`, 131 + alt_text: `takes from ${takeUploadedAt?.toISOString()}`, 128 132 }, 129 133 { 130 134 type: "divider", ··· 214 218 elements: [ 215 219 { 216 220 type: "mrkdwn", 217 - text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 221 + text: `take by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`, 218 222 }, 219 223 ], 220 224 }, ··· 248 252 }) 249 253 .where(eq(takesTable.id, takeId)); 250 254 255 + const takeDuration = calculateElapsedTime( 256 + JSON.parse(take[0]?.periods as string), 257 + ); 258 + 251 259 await slackClient.chat.postMessage({ 252 260 channel: payload.user.id, 253 261 thread_ts: take[0]?.ts as string, 254 - text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number(((take[0]?.durationMinutes as number) * Number(multiplier)) / 60).toFixed(1)} takes*!`, 262 + text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number((takeDuration * Number(multiplier)) / 60).toFixed(1)} takes*!`, 255 263 }); 256 264 257 265 // delete the message from the review channel
+20
src/features/takes/types.ts
··· 5 5 text: string; 6 6 response_type: "ephemeral" | "in_channel"; 7 7 }; 8 + 9 + export type PeriodType = "active" | "paused"; 10 + 11 + export interface TimePeriod { 12 + type: PeriodType; 13 + startTime: number; // timestamp 14 + endTime: number | null; // null means ongoing 15 + } 16 + 17 + export interface TakeTimeTracking { 18 + periods: TimePeriod[]; 19 + elapsedTimeMs: number; 20 + targetDurationMs: number; 21 + } 22 + 23 + export interface TakeTimeTrackingString { 24 + periods: string; 25 + elapsedTimeMs: number; 26 + targetDurationMs: number; 27 + }
+4 -5
src/libs/schema.ts
··· 6 6 userId: text("user_id").notNull(), 7 7 ts: text("ts"), 8 8 status: text("status").notNull().default("active"), // active, paused, waitingUpload, completed 9 - startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 10 - pausedAt: integer("paused_at", { mode: "timestamp" }), 9 + elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0), 10 + targetDurationMs: integer("target_duration_ms").notNull(), 11 + periods: text("periods").notNull(), // JSON string of time periods 12 + lastResumeAt: integer("last_resume_at", { mode: "timestamp" }), 11 13 completedAt: integer("completed_at", { mode: "timestamp" }), 12 14 takeUploadedAt: integer("take_uploaded_at", { mode: "timestamp" }), 13 15 takeUrl: text("take_url"), 14 - takeThumbUrl: text("take_thumb_url"), 15 16 multiplier: text("multiplier").notNull().default("1.0"), 16 - durationMinutes: integer("duration_minutes").notNull().default(5), // 5 minutes for testing (should be 90) 17 - pausedTimeMs: integer("paused_time_ms").notNull().default(0), // cumulative paused time 18 17 notes: text("notes"), 19 18 description: text("description"), 20 19 notifiedLowTime: integer("notified_low_time", { mode: "boolean" }).default(
+75
src/libs/time-periods.ts
··· 1 + import type { PeriodType, TimePeriod } from "../features/takes/types"; 2 + import TakesConfig from "./config"; 3 + 4 + export function calculateElapsedTime(periods: TimePeriod[]): number { 5 + return periods.reduce((total, period) => { 6 + if (period.type !== "active") return total; 7 + 8 + const endTime = period.endTime || Date.now(); 9 + return total + (endTime - period.startTime); 10 + }, 0); 11 + } 12 + 13 + export function addNewPeriod( 14 + periodsString: string, 15 + type: PeriodType, 16 + ): TimePeriod[] { 17 + const periods = JSON.parse(periodsString); 18 + 19 + // Close previous period if exists 20 + if (periods.length > 0) { 21 + const lastPeriod = periods[periods.length - 1]; 22 + if (!lastPeriod.endTime) { 23 + lastPeriod.endTime = Date.now(); 24 + } 25 + } 26 + 27 + // Add new period 28 + periods.push({ 29 + type, 30 + startTime: Date.now(), 31 + endTime: null, 32 + }); 33 + 34 + return periods; 35 + } 36 + 37 + export function getRemainingTime( 38 + targetDurationMs: number, 39 + periods: string, 40 + ): { 41 + remaining: number; 42 + endTime: Date; 43 + } { 44 + const elapsedMs = calculateElapsedTime(JSON.parse(periods)); 45 + const remaining = Math.max(0, targetDurationMs - elapsedMs); 46 + const endTime = new Date(Date.now() + remaining); 47 + return { remaining, endTime }; 48 + } 49 + 50 + export function getPausedTimeRemaining(periods: string): number { 51 + const parsedPeriods = JSON.parse(periods); 52 + const currentPeriod = parsedPeriods[parsedPeriods.length - 1]; 53 + 54 + if (currentPeriod.type !== "paused" || !currentPeriod.startTime) { 55 + return 0; 56 + } 57 + 58 + const now = new Date(); 59 + const pausedDuration = now.getTime() - currentPeriod.startTime; 60 + 61 + return Math.max( 62 + 0, 63 + TakesConfig.MAX_PAUSE_DURATION * 60 * 1000 - pausedDuration, 64 + ); 65 + } 66 + 67 + export function getPausedDuration(periods: string): number { 68 + const parsedPeriods = JSON.parse(periods); 69 + return parsedPeriods.reduce((total: number, period: TimePeriod) => { 70 + if (period.type !== "paused") return total; 71 + 72 + const endTime = period.endTime || Date.now(); 73 + return total + (endTime - period.startTime); 74 + }, 0); 75 + }