a fun bot for the hc slack

feat: move to periods time system

dunkirk.sh 8d2d6475 e4347ff7

verified
Changed files
+325 -180
src
+7
src/features/api/routes/video.ts
···
export default async function getVideo(url: URL): Promise<Response> {
const videoId = url.pathname.split("/")[2];
+
const thumbnail = url.pathname.split("/")[3] === "thumbnail";
if (!videoId) {
return new Response("Invalid video id", { status: 400 });
···
}
const videoData = video[0];
+
+
if (thumbnail) {
+
return Response.redirect(
+
`https://cachet.dunkirk.sh/users/${videoData?.userId}/r`,
+
);
+
}
return new Response(
`<!DOCTYPE html>
+5 -17
src/features/takes/handlers/history.ts
···
import TakesConfig from "../../../libs/config";
import { getCompletedTakes } from "../services/database";
import type { MessageResponse } from "../types";
+
import { calculateElapsedTime } from "../../../libs/time-periods";
+
import { prettyPrintTime } from "../../../libs/time";
export async function handleHistory(userId: string): Promise<MessageResponse> {
// Get completed takes for the user
···
type: "header",
text: {
type: "plain_text",
-
text: `📋 Your most recent ${completedTakes.length} Takes Sessions`,
+
text: `📋 Your most recent ${completedTakes.length} Takes sessions`,
emoji: true,
},
},
];
for (const take of completedTakes) {
-
const startTime = new Date(take.startedAt);
-
const endTime = take.completedAt || startTime;
-
-
// Calculate duration in minutes
-
const durationMs = endTime.getTime() - startTime.getTime();
-
const pausedMs = take.pausedTimeMs || 0;
-
const activeDuration = Math.round((durationMs - pausedMs) / 60000);
-
-
// Format dates
-
const startDate = `<!date^${Math.floor(startTime.getTime() / 1000)}^{date_short_pretty} at {time}|${startTime.toLocaleString()}>`;
-
const endDate = `<!date^${Math.floor(endTime.getTime() / 1000)}^{date_short_pretty} at {time}|${endTime.toLocaleString()}>`;
+
const elapsedTime = calculateElapsedTime(JSON.parse(take.periods));
const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
const description = take.description
···
type: "section",
text: {
type: "mrkdwn",
-
text: `*Take on ${startDate}*\n${description}• Duration: ${activeDuration} minutes${
-
pausedMs > 0
-
? ` (+ ${Math.round(pausedMs / 60000)} minutes paused)`
-
: ""
-
}\n• Started: ${startDate}\n• Completed: ${endDate}${notes}`,
+
text: `*Duration:* \`${prettyPrintTime(elapsedTime)}\`\n*Status:* ${take.status}\n${notes ? `*Notes:* ${take.notes}\n` : ""}${description ? `*Description:* ${take.description}\n` : ""}`,
},
});
+22 -12
src/features/takes/handlers/pause.ts
···
import TakesConfig from "../../../libs/config";
import { getActiveTake } from "../services/database";
import type { MessageResponse } from "../types";
-
import { prettyPrintTime } from "../../../libs/time";
+
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
+
import {
+
addNewPeriod,
+
getPausedTimeRemaining,
+
} from "../../../libs/time-periods";
export default async function handlePause(
userId: string,
···
return;
}
+
const newPeriods = JSON.stringify(
+
addNewPeriod(takeToUpdate.periods, "paused"),
+
);
+
+
const pausedTime = getPausedTimeRemaining(newPeriods);
+
+
if (pausedTime > TakesConfig.MAX_PAUSE_DURATION) {
+
return {
+
text: `You can't pause for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes!`,
+
response_type: "ephemeral",
+
};
+
}
+
// Update the takes entry to paused status
await db
.update(takesTable)
.set({
status: "paused",
-
pausedAt: new Date(),
+
periods: newPeriods,
notifiedPauseExpiration: false, // Reset pause expiration notification
})
.where(eq(takesTable.id, takeToUpdate.id));
-
// Calculate when the pause will expire
-
const pauseExpires = new Date();
-
pauseExpires.setMinutes(
-
pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
-
);
-
const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
-
return {
-
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.`,
+
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))}`,
response_type: "ephemeral",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
-
text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining.`,
+
text: `⏸️ Session paused! You have ${prettyPrintTime(TakesConfig.MAX_PAUSE_DURATION * 60000 - pausedTime)} remaining.`,
},
},
{
···
elements: [
{
type: "mrkdwn",
-
text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
+
text: `It will automatically finish at ${generateSlackDate(new Date(Date.now() + TakesConfig.MAX_PAUSE_DURATION * 60000))} if not resumed.`,
},
],
},
+19 -25
src/features/takes/handlers/resume.ts
···
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
import { getPausedTake } from "../services/database";
import type { MessageResponse } from "../types";
+
import { addNewPeriod, getRemainingTime } from "../../../libs/time-periods";
export default async function handleResume(
userId: string,
···
}
const now = new Date();
+
const newPeriods = JSON.stringify(
+
addNewPeriod(pausedSession.periods, "active"),
+
);
-
// Calculate paused time
-
if (pausedSession.pausedAt) {
-
const pausedTimeMs = now.getTime() - pausedSession.pausedAt.getTime();
-
const totalPausedTime =
-
(pausedSession.pausedTimeMs || 0) + pausedTimeMs;
-
-
// Update the takes entry to active status
-
await db
-
.update(takesTable)
-
.set({
-
status: "active",
-
pausedAt: null,
-
pausedTimeMs: totalPausedTime,
-
notifiedLowTime: false, // Reset low time notification
-
})
-
.where(eq(takesTable.id, pausedSession.id));
-
}
+
// Update the takes entry to active status
+
await db
+
.update(takesTable)
+
.set({
+
status: "active",
+
lastResumeAt: now,
+
periods: newPeriods,
+
notifiedLowTime: false, // Reset low time notification
+
})
+
.where(eq(takesTable.id, pausedSession.id));
-
const endTime = new Date(
-
new Date(pausedSession.startedAt).getTime() +
-
pausedSession.durationMinutes * 60000 +
-
(pausedSession.pausedTimeMs || 0),
+
const endTime = getRemainingTime(
+
pausedSession.targetDurationMs,
+
pausedSession.periods,
);
-
const timeRemaining = endTime.getTime() - now.getTime();
-
return {
-
text: `▶️ Takes session resumed! You have ${prettyPrintTime(timeRemaining)} remaining in your session.`,
+
text: `▶️ Takes session resumed! You have ${prettyPrintTime(endTime.remaining)} remaining in your session.`,
response_type: "ephemeral",
blocks: [
{
···
elements: [
{
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(timeRemaining)} remaining until ${generateSlackDate(endTime)}.`,
+
text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`,
},
],
},
+15 -8
src/features/takes/handlers/start.ts
···
import { takes as takesTable } from "../../../libs/schema";
import TakesConfig from "../../../libs/config";
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
+
import { getRemainingTime } from "../../../libs/time-periods";
export default async function handleStart(
userId: string,
channelId: string,
description?: string,
-
durationMinutes?: number,
): Promise<MessageResponse> {
const activeTake = await getActiveTake(userId);
if (activeTake.length > 0) {
···
const newTake = {
id: Bun.randomUUIDv7(),
userId,
-
channelId,
status: "active",
-
startedAt: new Date(),
-
durationMinutes: durationMinutes || TakesConfig.DEFAULT_SESSION_LENGTH,
+
targetDurationMs: TakesConfig.DEFAULT_SESSION_LENGTH * 60000,
+
periods: JSON.stringify([
+
{
+
type: "active",
+
startTime: Date.now(),
+
endTime: null,
+
},
+
]),
+
elapsedTimeMs: 0,
description: description || null,
notifiedLowTime: false,
notifiedPauseExpiration: false,
···
await db.insert(takesTable).values(newTake);
// Calculate end time for message
-
const endTime = new Date(
-
newTake.startedAt.getTime() + newTake.durationMinutes * 60000,
+
const endTime = getRemainingTime(
+
TakesConfig.DEFAULT_SESSION_LENGTH * 60000,
+
newTake.periods,
);
const descriptionText = description
? `\n\n*Working on:* ${description}`
: "";
return {
-
text: `🎬 Takes session started! You have ${prettyPrintTime(newTake.durationMinutes * 60000)} until ${generateSlackDate(endTime)}.${descriptionText}`,
+
text: `🎬 Takes session started! You have ${prettyPrintTime(endTime.remaining)} until ${generateSlackDate(endTime.endTime)}.${descriptionText}`,
response_type: "ephemeral",
blocks: [
{
···
elements: [
{
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(newTake.durationMinutes * 60000)} left until ${generateSlackDate(endTime)}.`,
+
text: `You have ${prettyPrintTime(endTime.remaining)} left until ${generateSlackDate(endTime.endTime)}.`,
},
],
},
+17 -34
src/features/takes/handlers/status.ts
···
import TakesConfig from "../../../libs/config";
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
import {
+
getPausedTimeRemaining,
+
getRemainingTime,
+
} from "../../../libs/time-periods";
+
import {
getActiveTake,
getCompletedTakes,
getPausedTake,
···
return;
}
-
const startTime = new Date(take.startedAt);
-
const endTime = new Date(
-
startTime.getTime() + take.durationMinutes * 60000,
-
);
-
-
// Adjust for paused time
-
if (take.pausedTimeMs) {
-
endTime.setTime(endTime.getTime() + take.pausedTimeMs);
-
}
-
-
const now = new Date();
-
const remainingMs = endTime.getTime() - now.getTime();
+
const endTime = getRemainingTime(take.targetDurationMs, take.periods);
// Add description to display if present
const descriptionText = take.description
···
: "";
return {
-
text: `🎬 You have an active takes session with ${prettyPrintTime(remainingMs)} remaining.${descriptionText}`,
+
text: `🎬 You have an active takes session with ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`,
response_type: "ephemeral",
blocks: [
{
···
elements: [
{
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(remainingMs)} remaining until ${generateSlackDate(endTime)}.`,
+
text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`,
},
],
},
···
if (pausedTakeStatus.length > 0) {
const pausedTake = pausedTakeStatus[0];
-
if (!pausedTake || !pausedTake.pausedAt) {
+
if (!pausedTake) {
return;
}
// Calculate how much time remains before auto-completion
-
const now = new Date();
-
const pausedDuration =
-
(now.getTime() - pausedTake.pausedAt.getTime()) / (60 * 1000); // In minutes
-
const remainingPauseTime = Math.max(
-
0,
-
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
+
const endTime = getRemainingTime(
+
pausedTake.targetDurationMs,
+
pausedTake.periods,
);
-
-
// Format the pause timeout
-
const pauseExpires = new Date(pausedTake.pausedAt);
-
pauseExpires.setMinutes(
-
pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
-
);
-
const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
+
const pauseExpires = getPausedTimeRemaining(pausedTake.periods);
// Add notes to display if present
const noteText = pausedTake.notes
···
: "";
return {
-
text: `⏸️ You have a paused takes session. It will auto-complete in ${remainingPauseTime.toFixed(1)} minutes if not resumed.`,
+
text: `⏸️ You have a paused takes session. It will auto-complete in ${prettyPrintTime(pauseExpires)} if not resumed.`,
response_type: "ephemeral",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
-
text: `⏸️ Session paused! You have ${prettyPrintTime(pausedTake.durationMinutes * 60000)} remaining.`,
+
text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.`,
},
},
{
···
elements: [
{
type: "mrkdwn",
-
text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
+
text: `It will automatically finish in ${prettyPrintTime(pauseExpires)} (by ${generateSlackDate(new Date(new Date().getTime() - pauseExpires))}) if not resumed.`,
},
],
},
···
const diffMs =
new Date().getTime() -
// @ts-expect-error - TS doesn't know that we are checking the length
-
completedSessions[
-
completedSessions.length - 1
-
].startedAt.getTime();
+
completedSessions[completedSessions.length - 1]
+
?.completedAt;
const hours = Math.ceil(diffMs / (1000 * 60 * 60));
if (hours < 24) return `${hours} hours`;
+55
src/features/takes/handlers/stop.ts
···
import { eq } from "drizzle-orm";
import { getActiveTake, getPausedTake } from "../services/database";
import type { MessageResponse } from "../types";
+
import { prettyPrintTime } from "../../../libs/time";
+
import {
+
calculateElapsedTime,
+
getRemainingTime,
+
} from "../../../libs/time-periods";
export default async function handleStop(
userId: string,
···
notes = args.slice(1).join(" ");
}
+
const elapsed = calculateElapsedTime(
+
JSON.parse(pausedTakeToStop.periods),
+
);
+
const res = await slackClient.chat.postMessage({
channel: userId,
text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
+
},
+
},
+
{
+
type: "divider",
+
},
+
{
+
type: "context",
+
elements: [
+
{
+
type: "mrkdwn",
+
text: `*Duration:* ${prettyPrintTime(elapsed)} ${notes ? `\n*Notes:* ${notes}` : ""}`,
+
},
+
],
+
},
+
],
});
await db
···
notes = args.slice(1).join(" ");
}
+
const elapsed = calculateElapsedTime(
+
JSON.parse(activeTakeToStop.periods),
+
);
+
const res = await slackClient.chat.postMessage({
channel: userId,
text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
+
},
+
},
+
{
+
type: "divider",
+
},
+
{
+
type: "context",
+
elements: [
+
{
+
type: "mrkdwn",
+
text: `*Duration:* ${prettyPrintTime(elapsed)} ${notes ? `\n*Notes:* ${notes}` : ""}`,
+
},
+
],
+
},
+
],
});
await db
+5 -2
src/features/takes/services/database.ts
···
import { db } from "../../../libs/db";
import { takes as takesTable } from "../../../libs/schema";
-
import { eq, and, desc } from "drizzle-orm";
+
import { eq, and, desc, not } from "drizzle-orm";
export async function getActiveTake(userId: string) {
return db
···
.where(
and(
eq(takesTable.userId, userId),
-
eq(takesTable.status, "completed"),
+
and(
+
not(eq(takesTable.status, "active")),
+
not(eq(takesTable.status, "paused")),
+
),
),
)
.orderBy(desc(takesTable.completedAt))
+64 -68
src/features/takes/services/notifications.ts
···
import { db } from "../../../libs/db";
import { takes as takesTable } from "../../../libs/schema";
import { eq } from "drizzle-orm";
+
import {
+
getPausedDuration,
+
getRemainingTime,
+
} from "../../../libs/time-periods";
// Check for paused sessions that have exceeded the max pause duration
export async function expirePausedSessions() {
···
.where(eq(takesTable.status, "paused"));
for (const take of pausedTakes) {
-
if (take.pausedAt) {
-
const pausedDuration =
-
(now.getTime() - take.pausedAt.getTime()) / (60 * 1000); // Convert to minutes
+
const pausedDuration = getPausedDuration(take.periods) / 60000; // Convert to minutes
-
// Send warning notification when getting close to expiration
-
if (
-
pausedDuration >
-
TakesConfig.MAX_PAUSE_DURATION -
-
TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING &&
-
!take.notifiedPauseExpiration
-
) {
-
// Update notification flag
-
await db
-
.update(takesTable)
-
.set({
-
notifiedPauseExpiration: true,
-
})
-
.where(eq(takesTable.id, take.id));
+
// Send warning notification when getting close to expiration
+
if (
+
pausedDuration >
+
TakesConfig.MAX_PAUSE_DURATION -
+
TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING &&
+
!take.notifiedPauseExpiration
+
) {
+
// Update notification flag
+
await db
+
.update(takesTable)
+
.set({
+
notifiedPauseExpiration: true,
+
})
+
.where(eq(takesTable.id, take.id));
-
// Send warning message
-
try {
-
const timeRemaining = Math.round(
-
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
-
);
-
await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`,
-
});
-
} catch (error) {
-
console.error(
-
"Failed to send pause expiration warning:",
-
error,
-
);
-
}
+
// Send warning message
+
try {
+
const timeRemaining = Math.round(
+
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
+
);
+
await slackApp.client.chat.postMessage({
+
channel: take.userId,
+
text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`,
+
});
+
} catch (error) {
+
console.error(
+
"Failed to send pause expiration warning:",
+
error,
+
);
}
-
-
// Auto-expire paused sessions that exceed the max pause duration
-
if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) {
-
let ts: string | undefined;
-
// Notify user that their session was auto-completed
-
try {
-
const res = await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
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!`,
-
});
-
ts = res.ts;
-
} catch (error) {
-
console.error(
-
"Failed to notify user of auto-completed session:",
-
error,
-
);
-
}
+
}
-
await db
-
.update(takesTable)
-
.set({
-
status: "waitingUpload",
-
completedAt: now,
-
ts,
-
notes: take.notes
-
? `${take.notes} (Automatically completed due to pause timeout)`
-
: "Automatically completed due to pause timeout",
-
})
-
.where(eq(takesTable.id, take.id));
+
// Auto-expire paused sessions that exceed the max pause duration
+
if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) {
+
let ts: string | undefined;
+
// Notify user that their session was auto-completed
+
try {
+
const res = await slackApp.client.chat.postMessage({
+
channel: take.userId,
+
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!`,
+
});
+
ts = res.ts;
+
} catch (error) {
+
console.error(
+
"Failed to notify user of auto-completed session:",
+
error,
+
);
}
+
+
await db
+
.update(takesTable)
+
.set({
+
status: "waitingUpload",
+
completedAt: now,
+
ts,
+
notes: take.notes
+
? `${take.notes} (Automatically completed due to pause timeout)`
+
: "Automatically completed due to pause timeout",
+
})
+
.where(eq(takesTable.id, take.id));
}
}
}
···
.where(eq(takesTable.status, "active"));
for (const take of activeTakes) {
-
const endTime = new Date(
-
take.startedAt.getTime() +
-
take.durationMinutes * 60000 +
-
(take.pausedTimeMs || 0),
-
);
+
const endTime = getRemainingTime(take.targetDurationMs, take.periods);
-
const remainingMs = endTime.getTime() - now.getTime();
-
const remainingMinutes = remainingMs / 60000;
+
const remainingMinutes = endTime.remaining / 60000;
if (
remainingMinutes <= TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING &&
···
}
}
-
if (remainingMs <= 0) {
+
if (endTime.remaining <= 0) {
let ts: string | undefined;
try {
const res = await slackApp.client.chat.postMessage({
+17 -9
src/features/takes/services/upload.ts
···
import { takes as takesTable } from "../../../libs/schema";
import { eq, and } from "drizzle-orm";
import { prettyPrintTime } from "../../../libs/time";
+
import { calculateElapsedTime } from "../../../libs/time-periods";
export default async function upload() {
slackApp.anyMessage(async ({ payload }) => {
···
const match = html.match(/src="([^"]*\.mp4[^"]*)"/);
const takePublicUrl = match?.[1];
+
const takeUploadedAt = new Date();
+
await db
.update(takesTable)
.set({
status: "uploaded",
-
takeUploadedAt: new Date(),
+
takeUploadedAt,
takeUrl: takePublicUrl,
-
takeThumbUrl: file?.thumb_video,
})
.where(eq(takesTable.id, take.id));
···
name: "fire",
});
+
const takeDuration = calculateElapsedTime(JSON.parse(take.periods));
+
await slackClient.chat.postMessage({
channel: payload.channel,
thread_ts: payload.thread_ts,
···
elements: [
{
type: "mrkdwn",
-
text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`,
+
text: `take by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`,
},
],
},
···
await slackClient.chat.postMessage({
channel: process.env.SLACK_REVIEW_CHANNEL || "",
-
text: "",
+
text: ":video_camera: new take uploaded!",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
-
text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`,
+
text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`,
},
},
{
···
title_url: `${process.env.API_URL}/video/${take.id}`,
title: {
type: "plain_text",
-
text: `take on ${take.takeUploadedAt?.toISOString()}`,
+
text: `takes from ${takeUploadedAt?.toISOString()}`,
},
thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`,
-
alt_text: `take on ${take.takeUploadedAt?.toISOString()}`,
+
alt_text: `takes from ${takeUploadedAt?.toISOString()}`,
},
{
type: "divider",
···
elements: [
{
type: "mrkdwn",
-
text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`,
+
text: `take by <@${user}> for \`${prettyPrintTime(takeDuration)}\` working on: *${take.description}*`,
},
],
},
···
})
.where(eq(takesTable.id, takeId));
+
const takeDuration = calculateElapsedTime(
+
JSON.parse(take[0]?.periods as string),
+
);
+
await slackClient.chat.postMessage({
channel: payload.user.id,
thread_ts: take[0]?.ts as string,
-
text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number(((take[0]?.durationMinutes as number) * Number(multiplier)) / 60).toFixed(1)} takes*!`,
+
text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number((takeDuration * Number(multiplier)) / 60).toFixed(1)} takes*!`,
});
// delete the message from the review channel
+20
src/features/takes/types.ts
···
text: string;
response_type: "ephemeral" | "in_channel";
};
+
+
export type PeriodType = "active" | "paused";
+
+
export interface TimePeriod {
+
type: PeriodType;
+
startTime: number; // timestamp
+
endTime: number | null; // null means ongoing
+
}
+
+
export interface TakeTimeTracking {
+
periods: TimePeriod[];
+
elapsedTimeMs: number;
+
targetDurationMs: number;
+
}
+
+
export interface TakeTimeTrackingString {
+
periods: string;
+
elapsedTimeMs: number;
+
targetDurationMs: number;
+
}
+4 -5
src/libs/schema.ts
···
userId: text("user_id").notNull(),
ts: text("ts"),
status: text("status").notNull().default("active"), // active, paused, waitingUpload, completed
-
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
-
pausedAt: integer("paused_at", { mode: "timestamp" }),
+
elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0),
+
targetDurationMs: integer("target_duration_ms").notNull(),
+
periods: text("periods").notNull(), // JSON string of time periods
+
lastResumeAt: integer("last_resume_at", { mode: "timestamp" }),
completedAt: integer("completed_at", { mode: "timestamp" }),
takeUploadedAt: integer("take_uploaded_at", { mode: "timestamp" }),
takeUrl: text("take_url"),
-
takeThumbUrl: text("take_thumb_url"),
multiplier: text("multiplier").notNull().default("1.0"),
-
durationMinutes: integer("duration_minutes").notNull().default(5), // 5 minutes for testing (should be 90)
-
pausedTimeMs: integer("paused_time_ms").notNull().default(0), // cumulative paused time
notes: text("notes"),
description: text("description"),
notifiedLowTime: integer("notified_low_time", { mode: "boolean" }).default(
+75
src/libs/time-periods.ts
···
+
import type { PeriodType, TimePeriod } from "../features/takes/types";
+
import TakesConfig from "./config";
+
+
export function calculateElapsedTime(periods: TimePeriod[]): number {
+
return periods.reduce((total, period) => {
+
if (period.type !== "active") return total;
+
+
const endTime = period.endTime || Date.now();
+
return total + (endTime - period.startTime);
+
}, 0);
+
}
+
+
export function addNewPeriod(
+
periodsString: string,
+
type: PeriodType,
+
): TimePeriod[] {
+
const periods = JSON.parse(periodsString);
+
+
// Close previous period if exists
+
if (periods.length > 0) {
+
const lastPeriod = periods[periods.length - 1];
+
if (!lastPeriod.endTime) {
+
lastPeriod.endTime = Date.now();
+
}
+
}
+
+
// Add new period
+
periods.push({
+
type,
+
startTime: Date.now(),
+
endTime: null,
+
});
+
+
return periods;
+
}
+
+
export function getRemainingTime(
+
targetDurationMs: number,
+
periods: string,
+
): {
+
remaining: number;
+
endTime: Date;
+
} {
+
const elapsedMs = calculateElapsedTime(JSON.parse(periods));
+
const remaining = Math.max(0, targetDurationMs - elapsedMs);
+
const endTime = new Date(Date.now() + remaining);
+
return { remaining, endTime };
+
}
+
+
export function getPausedTimeRemaining(periods: string): number {
+
const parsedPeriods = JSON.parse(periods);
+
const currentPeriod = parsedPeriods[parsedPeriods.length - 1];
+
+
if (currentPeriod.type !== "paused" || !currentPeriod.startTime) {
+
return 0;
+
}
+
+
const now = new Date();
+
const pausedDuration = now.getTime() - currentPeriod.startTime;
+
+
return Math.max(
+
0,
+
TakesConfig.MAX_PAUSE_DURATION * 60 * 1000 - pausedDuration,
+
);
+
}
+
+
export function getPausedDuration(periods: string): number {
+
const parsedPeriods = JSON.parse(periods);
+
return parsedPeriods.reduce((total: number, period: TimePeriod) => {
+
if (period.type !== "paused") return total;
+
+
const endTime = period.endTime || Date.now();
+
return total + (endTime - period.startTime);
+
}, 0);
+
}