a fun bot for the hc slack

bug: fix thread misfire, move to seconds, and back to hackatime ids

dunkirk.sh a8c672b1 2a39e985

verified
Changed files
+107 -38
src
+1
src/features/api/routes/projects.ts
···
projectName: string;
projectDescription: string;
projectBannerUrl: string;
+
/** Total time spent on takes, in seconds */
totalTakesTime: number;
userId: string;
};
+6 -4
src/features/api/routes/recentTakes.ts
···
-
import { eq, desc, and, or } from "drizzle-orm";
+
import { eq, desc, or } from "drizzle-orm";
import { db } from "../../../libs/db";
import { takes as takesTable, users as usersTable } from "../../../libs/schema";
import { handleApiError } from "../../../libs/apiError";
···
notes: string;
createdAt: Date;
mediaUrls: string[];
-
elapsedTimeMs: number;
+
/* elapsed time in seconds */
+
elapsedTime: number;
project: string;
+
/* total time in seconds */
totalTakesTime: number;
};
···
notes: take.notes,
createdAt: new Date(take.createdAt),
mediaUrls: take.media ? JSON.parse(take.media) : [],
-
elapsedTimeMs: take.elapsedTimeMs,
+
elapsedTime: take.elapsedTime,
project: userMap[take.userId]?.projectName || "unknown project",
totalTakesTime:
-
userMap[take.userId]?.totalTakesTime || take.elapsedTimeMs,
+
userMap[take.userId]?.totalTakesTime || take.elapsedTime,
})) || [];
return new Response(
+3 -1
src/features/frontend/pages/ProjectTakes.tsx
···
Duration:
</span>
<span className="meta-value">
-
{prettyPrintTime(take.elapsedTimeMs)}
+
{prettyPrintTime(
+
take.elapsedTime * 1000,
+
)}
</span>
</div>
</div>
+3 -1
src/features/frontend/pages/Projects.tsx
···
<div className="project-meta">
<span>
Total Time:{" "}
-
{prettyPrintTime(project.totalTakesTime)}
+
{prettyPrintTime(
+
project.totalTakesTime * 1000,
+
)}
</span>
</div>
</Link>
+2 -2
src/features/takes/handlers/history.ts
···
.orderBy(desc(takesTable.createdAt));
const takeTimeMs = takes.reduce(
-
(acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier),
+
(acc, take) => acc + take.elapsedTime * 1000 * Number(take.multiplier),
0,
);
const takeTime = prettyPrintTime(takeTimeMs);
···
for (const take of takes) {
const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
-
const duration = prettyPrintTime(take.elapsedTimeMs);
+
const duration = prettyPrintTime(take.elapsedTime * 1000);
historyBlocks.push({
type: "section",
+3 -5
src/features/takes/handlers/settings.ts
···
project_description: existingUser.projectDescription,
repo_link: existingUser.repoLink || undefined,
demo_link: existingUser.demoLink || undefined,
-
hackatime_version: getHackatimeVersion(
-
existingUser.hackatimeBaseAPI,
-
),
+
hackatime_version: existingUser.hackatimeVersion,
};
}
} catch (error) {
···
demoLink: values.project_link?.demo_link?.value as
| string
| undefined,
-
hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion),
+
hackatimeVersion,
})
.onConflictDoUpdate({
target: usersTable.id,
···
demoLink: values.demo_link?.demo_link_input?.value as
| string
| undefined,
-
hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion),
+
hackatimeVersion,
},
});
} catch (error) {
+2 -1
src/features/takes/handlers/upload.ts
···
if (
payload.subtype === "bot_message" ||
payload.subtype === "thread_broadcast" ||
+
payload.thread_ts ||
payload.channel !== process.env.SLACK_LISTEN_CHANNEL
)
return;
···
ts: payload.ts,
notes: markdownText,
media: JSON.stringify(mediaUrls),
-
elapsedTimeMs: timeSpentMs,
+
elapsedTime: timeSpentMs / 1000,
});
await slackClient.reactions.add({
+63
src/libs/hackatime.ts
···
}
return "v2";
}
+
+
/**
+
* Fetches a user's Hackatime summary
+
* @param userId The user ID to fetch the summary for
+
* @param version The Hackatime version to use (defaults to v2)
+
* @param projectKeys Optional array of project keys to filter results by
+
* @returns A promise that resolves to the summary data
+
*/
+
export async function fetchHackatimeSummary(
+
userId: string,
+
version: HackatimeVersion = "v2",
+
projectKeys?: string[],
+
) {
+
const apiUrl = getHackatimeApiUrl(version);
+
const response = await fetch(
+
`${apiUrl}/summary?user=${userId}&interval=month`,
+
{
+
headers: {
+
accept: "application/json",
+
Authorization: "Bearer 2ce9e698-8a16-46f0-b49a-ac121bcfd608",
+
},
+
},
+
);
+
+
if (!response.ok) {
+
throw new Error(
+
`Failed to fetch Hackatime summary: ${response.status} ${response.statusText}`,
+
);
+
}
+
+
const data = await response.json();
+
+
// Add derived properties similar to the shell command
+
const totalCategoriesSum =
+
data.categories?.reduce(
+
(sum: number, category: { total: number }) => sum + category.total,
+
0,
+
) || 0;
+
const hours = Math.floor(totalCategoriesSum / 3600);
+
const minutes = Math.floor((totalCategoriesSum % 3600) / 60);
+
const seconds = totalCategoriesSum % 60;
+
+
// Get all project keys from the data
+
const allProjectsKeys =
+
data.projects
+
?.sort(
+
(a: { total: number }, b: { total: number }) =>
+
b.total - a.total,
+
)
+
.map((project: { key: string }) => project.key) || [];
+
+
// Filter by provided project keys if any
+
const projectsKeys = projectKeys
+
? allProjectsKeys.filter((key: string) => projectKeys.includes(key))
+
: allProjectsKeys;
+
+
return {
+
...data,
+
total_categories_sum: totalCategoriesSum,
+
total_categories_human_readable: `${hours}h ${minutes}m ${seconds}s`,
+
projectsKeys: projectsKeys,
+
};
+
}
+24 -24
src/libs/schema.ts
···
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
ts: text("ts").notNull(),
-
elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0),
+
/* elapsed time in seconds */
+
elapsedTime: integer("elapsed_time").notNull().default(0),
createdAt: text("created_at")
.$defaultFn(() => new Date().toISOString())
.notNull(),
···
id: text("id")
.primaryKey()
.$defaultFn(() => Bun.randomUUIDv7()),
+
/* total time in seconds */
totalTakesTime: integer("total_takes_time").default(0).notNull(),
hackatimeKeys: text("hackatime_keys").notNull().default("[]"),
projectName: text("project_name").notNull().default(""),
projectDescription: text("project_description").notNull().default(""),
projectBannerUrl: text("project_banner_url").notNull().default(""),
-
hackatimeBaseAPI: text("hackatime_base_api")
-
.notNull()
-
.default("https://hackatime.hackclub.com/api"),
+
hackatimeVersion: text("hackatime_version").notNull().default("v2"),
repoLink: text("repo_link"),
demoLink: text("demo_link"),
});
···
CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id);
CREATE OR REPLACE FUNCTION update_user_total_time()
-
RETURNS TRIGGER AS $$
-
BEGIN
-
IF TG_OP = 'INSERT' THEN
-
UPDATE users
-
SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time_ms
-
WHERE id = NEW.user_id;
-
RETURN NEW;
-
ELSIF TG_OP = 'DELETE' THEN
-
UPDATE users
-
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms
-
WHERE id = OLD.user_id;
-
RETURN OLD;
-
ELSIF TG_OP = 'UPDATE' THEN
-
UPDATE users
-
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms + NEW.elapsed_time_ms
-
WHERE id = NEW.user_id;
-
RETURN NEW;
-
END IF;
+
RETURNS TRIGGER AS $$
+
BEGIN
+
IF TG_OP = 'INSERT' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time
+
WHERE id = NEW.user_id;
+
RETURN NEW;
+
ELSIF TG_OP = 'DELETE' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time
+
WHERE id = OLD.user_id;
+
RETURN OLD;
+
ELSIF TG_OP = 'UPDATE' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time + NEW.elapsed_time
+
WHERE id = NEW.user_id;
+
RETURN NEW;
+
END IF;
-
RETURN NULL; -- Default return for unexpected operations
+
RETURN NULL; -- Default return for unexpected operations
-
EXCEPTION WHEN OTHERS THEN
+
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error updating user total time: %', SQLERRM;
RETURN NULL;
END;