This repository has no description
0

Configure Feed

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

feat: move to hackatime v1 and enable hackatime time tracking

+154 -130
+35 -80
src/features/takes/handlers/settings.ts
··· 20 20 project_description: string; 21 21 repo_link: string | undefined; 22 22 demo_link: string | undefined; 23 - hackatime_version: string; 23 + hackatime_version: HackatimeVersion; 24 24 hackatime_keys: string[]; 25 25 } = { 26 26 project_name: "", 27 27 project_description: "", 28 28 repo_link: undefined, 29 29 demo_link: undefined, 30 - hackatime_version: "v2", 30 + hackatime_version: "v1", 31 31 hackatime_keys: [], 32 32 }; 33 33 ··· 47 47 project_description: existingUser.projectDescription, 48 48 repo_link: existingUser.repoLink || undefined, 49 49 demo_link: existingUser.demoLink || undefined, 50 - hackatime_version: existingUser.hackatimeVersion, 50 + hackatime_version: 51 + existingUser.hackatimeVersion as HackatimeVersion, 51 52 hackatime_keys: existingUser.hackatimeKeys 52 53 ? JSON.parse(existingUser.hackatimeKeys) 53 54 : [], ··· 82 83 element: { 83 84 type: "plain_text_input", 84 85 action_id: "project_name_input", 85 - initial_value: initialValues.project_name || "", 86 + initial_value: initialValues.project_name, 86 87 placeholder: { 87 88 type: "plain_text", 88 89 text: "Enter your project name", ··· 100 101 type: "plain_text_input", 101 102 action_id: "project_description_input", 102 103 multiline: true, 103 - initial_value: initialValues.project_description || "", 104 + initial_value: initialValues.project_description, 104 105 placeholder: { 105 106 type: "plain_text", 106 107 text: "Describe your project", ··· 131 132 element: { 132 133 type: "plain_text_input", 133 134 action_id: "repo_link_input", 134 - initial_value: initialValues.repo_link || "", 135 + initial_value: initialValues.repo_link, 135 136 placeholder: { 136 137 type: "plain_text", 137 138 text: "Optional: Add a link to your repository", ··· 149 150 element: { 150 151 type: "plain_text_input", 151 152 action_id: "demo_link_input", 152 - initial_value: initialValues.demo_link || "", 153 + initial_value: initialValues.demo_link, 153 154 placeholder: { 154 155 type: "plain_text", 155 156 text: "Optional: Add a link to your demo", ··· 170 171 text: { 171 172 type: "plain_text", 172 173 text: getHackatimeName( 173 - initialValues.hackatime_version as HackatimeVersion, 174 + initialValues.hackatime_version, 174 175 ), 175 176 }, 176 177 value: initialValues.hackatime_version, ··· 178 179 options: Object.values(HACKATIME_VERSIONS).map((v) => ({ 179 180 text: { 180 181 type: "plain_text", 181 - text: getHackatimeName(v.id), 182 + text: v.name, 182 183 }, 183 184 value: v.id, 184 185 })), ··· 194 195 element: { 195 196 type: "multi_static_select", 196 197 action_id: "project_keys_input", 197 - initial_options: initialValues.hackatime_keys.map( 198 - (key) => ({ 199 - text: { 200 - type: "plain_text", 201 - text: key, 202 - }, 203 - value: key, 204 - }), 205 - ), 198 + initial_options: 199 + initialValues.hackatime_keys.length === 0 200 + ? undefined 201 + : initialValues.hackatime_keys.map((key) => ({ 202 + text: { 203 + type: "plain_text", 204 + text: key, 205 + }, 206 + value: key, 207 + })), 206 208 options: ( 207 209 await fetchRecentProjectKeys( 208 210 user, ··· 224 226 } 225 227 226 228 export async function setupSubmitListener() { 227 - slackApp.view("takes_setup_submit", async ({ payload, body }) => { 229 + slackApp.view("takes_setup_submit", async ({ payload, context }) => { 228 230 if (payload.type !== "view_submission") return; 229 231 const values = payload.view.state.values; 230 - const userId = body.user.id; 232 + const userId = payload.user.id; 231 233 232 234 const file = values.project_banner?.project_banner_input?.files?.[0] 233 235 ?.url_private_download as string; ··· 253 255 .insert(usersTable) 254 256 .values({ 255 257 id: userId, 256 - projectName: values.project_name?.project_name_input 257 - ?.value as string, 258 - projectDescription: values.project_description 259 - ?.project_description_input?.value as string, 258 + projectName: values.project_name?.project_name_input?.value, 259 + projectDescription: 260 + values.project_description?.project_description_input 261 + ?.value, 260 262 projectBannerUrl, 261 - repoLink: values.repo_link?.repo_link_input?.value as 262 - | string 263 - | undefined, 264 - demoLink: values.demo_link?.demo_link_input?.value as 265 - | string 266 - | undefined, 263 + repoLink: values.repo_link?.repo_link_input?.value, 264 + demoLink: values.demo_link?.demo_link_input?.value, 267 265 hackatimeVersion, 268 266 hackatimeKeys, 269 267 }) 270 268 .onConflictDoUpdate({ 271 269 target: usersTable.id, 272 270 set: { 273 - projectName: values.project_name?.project_name_input 274 - ?.value as string, 275 - projectDescription: values.project_description 276 - ?.project_description_input?.value as string, 271 + projectName: 272 + values.project_name?.project_name_input?.value, 273 + projectDescription: 274 + values.project_description 275 + ?.project_description_input?.value, 277 276 projectBannerUrl, 278 - repoLink: values.repo_link?.repo_link_input?.value as 279 - | string 280 - | undefined, 281 - demoLink: values.demo_link?.demo_link_input?.value as 282 - | string 283 - | undefined, 277 + repoLink: values.repo_link?.repo_link_input?.value, 278 + demoLink: values.demo_link?.demo_link_input?.value, 284 279 hackatimeVersion, 285 280 hackatimeKeys, 286 281 }, 287 282 }); 288 - 289 - // Update the view to show the latest Hackatime project keys 290 - await slackClient.views.update({ 291 - view_id: payload.view.id, 292 - view: { 293 - type: "modal", 294 - title: { 295 - type: "plain_text", 296 - text: "Add your hackatime keys", 297 - }, 298 - blocks: [ 299 - { 300 - type: "section", 301 - text: { 302 - type: "mrkdwn", 303 - text: ":white_check_mark: Your project has been updated successfully!", 304 - }, 305 - }, 306 - { 307 - type: "section", 308 - text: { 309 - type: "mrkdwn", 310 - text: "*Hackatime Project Keys:*", 311 - }, 312 - }, 313 - { 314 - type: "section", 315 - text: { 316 - type: "mrkdwn", 317 - text: Object.values(HACKATIME_VERSIONS) 318 - .map( 319 - (v) => 320 - `• *${getHackatimeName(v.id)}*: \`${v.id}\``, 321 - ) 322 - .join("\n"), 323 - }, 324 - }, 325 - ], 326 - }, 327 - }); 328 283 } catch (error) { 329 284 console.error("Error processing file:", error); 330 285 throw error;
+17 -5
src/features/takes/handlers/upload.ts
··· 3 3 import { db } from "../../../libs/db"; 4 4 import { takes as takesTable, users as usersTable } from "../../../libs/schema"; 5 5 import * as Sentry from "@sentry/bun"; 6 + import { 7 + fetchHackatimeSummary, 8 + type HackatimeVersion, 9 + } from "../../../libs/hackatime"; 10 + import { prettyPrintTime } from "../../../libs/time"; 6 11 7 12 export default async function upload() { 8 13 slackApp.anyMessage(async ({ payload, context }) => { ··· 20 25 const userInDB = await db 21 26 .select() 22 27 .from(usersTable) 23 - .where(eq(usersTable.id, user)); 28 + .where(eq(usersTable.id, user)) 29 + .then((users) => users[0] || null); 24 30 25 - if (userInDB.length === 0) { 31 + if (!userInDB) { 26 32 await slackClient.chat.postMessage({ 27 33 channel: payload.channel, 28 34 thread_ts: payload.ts, ··· 129 135 } 130 136 131 137 // fetch time spent on project via hackatime 132 - const timeSpentMs = 60000; 138 + const timeSpent = await fetchHackatimeSummary( 139 + user, 140 + userInDB.hackatimeVersion as HackatimeVersion, 141 + JSON.parse(userInDB.hackatimeKeys), 142 + new Date(userInDB.lastTakeUploadDate), 143 + new Date(), 144 + ).then((res) => res.total_categories_sum || 0); 133 145 134 146 await db.insert(takesTable).values({ 135 147 id: Bun.randomUUIDv7(), ··· 137 149 ts: payload.ts, 138 150 notes: markdownText, 139 151 media: JSON.stringify(mediaUrls), 140 - elapsedTime: timeSpentMs / 1000, 152 + elapsedTime: timeSpent, 141 153 }); 142 154 143 155 await slackClient.reactions.add({ ··· 155 167 type: "section", 156 168 text: { 157 169 type: "mrkdwn", 158 - text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes!`, 170 + text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes! That's \`${prettyPrintTime(timeSpent * 1000)}\`!`, 159 171 }, 160 172 }, 161 173 ],
+54 -11
src/libs/hackatime.ts
··· 9 9 }, 10 10 v2: { 11 11 id: "v2", 12 - name: "Hackatime v2", 13 - apiUrl: "https://hackatime.hackclub.com/api", 12 + name: "Hackatime V2 (broken don't use)", 13 + apiUrl: "https://waka.hackclub.com/api", 14 14 }, 15 15 } as const; 16 16 ··· 18 18 19 19 /** 20 20 * Converts a Hackatime version identifier to its full API URL 21 - * @param version The version identifier (v1 or v2) 21 + * @param version The version identifier (v1 or v2 soon) 22 22 * @returns The corresponding API URL 23 23 */ 24 24 export function getHackatimeApiUrl(version: HackatimeVersion): string { ··· 27 27 28 28 /** 29 29 * Gets the fancy name for a Hackatime version 30 - * @param version The version identifier (v1 or v2) 30 + * @param version The version identifier (v1 or v2 soon) 31 31 * @returns The fancy display name for the version 32 32 */ 33 33 export function getHackatimeName(version: HackatimeVersion): string { ··· 37 37 /** 38 38 * Determines which Hackatime version is being used based on the API URL 39 39 * @param apiUrl The full Hackatime API URL 40 - * @returns The version identifier (v1 or v2), defaulting to v2 if not recognized 40 + * @returns The version identifier (v1 or v2 soon), defaulting to v1 if not recognized 41 41 */ 42 42 export function getHackatimeVersion(apiUrl: string): HackatimeVersion { 43 43 for (const [version, data] of Object.entries(HACKATIME_VERSIONS)) { ··· 45 45 return version as HackatimeVersion; 46 46 } 47 47 } 48 - return "v2"; 48 + return "v1"; 49 + } 50 + 51 + /** 52 + * Type definition for Hackatime summary response 53 + */ 54 + export interface HackatimeSummaryResponse { 55 + categories?: Array<{ 56 + name: string; 57 + total: number; 58 + percent?: number; 59 + }>; 60 + projects?: Array<{ 61 + key: string; 62 + name: string; 63 + total: number; 64 + percent?: number; 65 + last_used_at: string; 66 + }>; 67 + languages?: Array<{ 68 + name: string; 69 + total: number; 70 + percent?: number; 71 + }>; 72 + editors?: Array<{ 73 + name: string; 74 + total: number; 75 + percent?: number; 76 + }>; 77 + operating_systems?: Array<{ 78 + name: string; 79 + total: number; 80 + percent?: number; 81 + }>; 82 + range?: { 83 + start: string; 84 + end: string; 85 + timezone: string; 86 + }; 87 + total_categories_sum?: number; 88 + total_categories_human_readable?: string; 89 + projectsKeys?: string[]; 49 90 } 50 91 51 92 /** 52 93 * Fetches a user's Hackatime summary 53 94 * @param userId The user ID to fetch the summary for 54 - * @param version The Hackatime version to use (defaults to v2) 95 + * @param version The Hackatime version to use (defaults to v1) 55 96 * @param projectKeys Optional array of project keys to filter results by 97 + * @param from Optional start date for the summary 98 + * @param to Optional end date for the summary 56 99 * @returns A promise that resolves to the summary data 57 100 */ 58 101 export async function fetchHackatimeSummary( 59 102 userId: string, 60 - version: HackatimeVersion = "v2", 103 + version: HackatimeVersion = "v1", 61 104 projectKeys?: string[], 62 105 from?: Date, 63 106 to?: Date, 64 - ) { 107 + ): Promise<HackatimeSummaryResponse> { 65 108 const apiUrl = getHackatimeApiUrl(version); 66 109 const params = new URLSearchParams({ 67 110 user: userId, ··· 124 167 * Fetches the most recent project keys from a user's Hackatime data 125 168 * @param userId The user ID to fetch the project keys for 126 169 * @param limit The maximum number of projects to return (defaults to 10) 127 - * @param version The Hackatime version to use (defaults to v2) 170 + * @param version The Hackatime version to use (defaults to v1) 128 171 * @returns A promise that resolves to an array of recent project keys 129 172 */ 130 173 export async function fetchRecentProjectKeys( 131 174 userId: string, 132 175 limit = 10, 133 - version: HackatimeVersion = "v2", 176 + version: HackatimeVersion = "v1", 134 177 ): Promise<string[]> { 135 178 const summary = await fetchHackatimeSummary(userId, version); 136 179
+39 -33
src/libs/schema.ts
··· 1 1 import { pgTable, text, integer, boolean } from "drizzle-orm/pg-core"; 2 2 import type { Pool } from "pg"; 3 + import TakesConfig from "./config"; 3 4 4 5 // Define the takes table 5 6 export const takes = pgTable("takes", { ··· 26 27 projectName: text("project_name").notNull().default(""), 27 28 projectDescription: text("project_description").notNull().default(""), 28 29 projectBannerUrl: text("project_banner_url").notNull().default(""), 29 - hackatimeVersion: text("hackatime_version").notNull().default("v2"), 30 + hackatimeVersion: text("hackatime_version").notNull().default("v1"), 31 + lastTakeUploadDate: text("last_take_upload_date") 32 + .notNull() 33 + .default(TakesConfig.START_DATE.toISOString()), 30 34 repoLink: text("repo_link"), 31 35 demoLink: text("demo_link"), 32 36 }); 33 37 34 38 export async function setupTriggers(pool: Pool) { 35 39 await pool.query(` 36 - CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id); 40 + CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id); 37 41 38 - CREATE OR REPLACE FUNCTION update_user_total_time() 39 - RETURNS TRIGGER AS $$ 40 - BEGIN 41 - IF TG_OP = 'INSERT' THEN 42 - UPDATE users 43 - SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time 44 - WHERE id = NEW.user_id; 45 - RETURN NEW; 46 - ELSIF TG_OP = 'DELETE' THEN 47 - UPDATE users 48 - SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time 49 - WHERE id = OLD.user_id; 50 - RETURN OLD; 51 - ELSIF TG_OP = 'UPDATE' THEN 52 - UPDATE users 53 - SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time + NEW.elapsed_time 54 - WHERE id = NEW.user_id; 55 - RETURN NEW; 56 - END IF; 42 + CREATE OR REPLACE FUNCTION update_user_total_time() 43 + RETURNS TRIGGER AS $$ 44 + BEGIN 45 + IF TG_OP = 'INSERT' THEN 46 + UPDATE users 47 + SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time, 48 + last_take_upload_date = NEW.created_at 49 + WHERE id = NEW.user_id; 50 + RETURN NEW; 51 + ELSIF TG_OP = 'DELETE' THEN 52 + UPDATE users 53 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time 54 + WHERE id = OLD.user_id; 55 + RETURN OLD; 56 + ELSIF TG_OP = 'UPDATE' THEN 57 + UPDATE users 58 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time + NEW.elapsed_time, 59 + last_take_upload_date = CASE WHEN NEW.created_at > OLD.created_at THEN NEW.created_at ELSE users.last_take_upload_date END 60 + WHERE id = NEW.user_id; 61 + RETURN NEW; 62 + END IF; 57 63 58 - RETURN NULL; -- Default return for unexpected operations 64 + RETURN NULL; -- Default return for unexpected operations 59 65 60 - EXCEPTION WHEN OTHERS THEN 61 - RAISE NOTICE 'Error updating user total time: %', SQLERRM; 62 - RETURN NULL; 63 - END; 64 - $$ LANGUAGE plpgsql; 66 + EXCEPTION WHEN OTHERS THEN 67 + RAISE NOTICE 'Error updating user total time: %', SQLERRM; 68 + RETURN NULL; 69 + END; 70 + $$ LANGUAGE plpgsql; 65 71 66 - DROP TRIGGER IF EXISTS update_user_total_time_trigger ON takes; 72 + DROP TRIGGER IF EXISTS update_user_total_time_trigger ON takes; 67 73 68 - CREATE TRIGGER update_user_total_time_trigger 69 - AFTER INSERT OR UPDATE OR DELETE ON takes 70 - FOR EACH ROW 71 - EXECUTE FUNCTION update_user_total_time(); 72 - `); 74 + CREATE TRIGGER update_user_total_time_trigger 75 + AFTER INSERT OR UPDATE OR DELETE ON takes 76 + FOR EACH ROW 77 + EXECUTE FUNCTION update_user_total_time(); 78 + `); 73 79 }
+9 -1
src/libs/time.ts
··· 1 1 // Helper function for pretty-printing time 2 2 export const prettyPrintTime = (ms: number): string => { 3 - const minutes = Math.round(ms / 60000); 3 + const hours = Math.floor(ms / 3600000); 4 + const minutes = Math.floor((ms % 3600000) / 60000); 5 + 6 + if (hours > 0 && minutes > 5) { 7 + return `${hours} hours and ${minutes} minutes`; 8 + } 9 + if (hours > 0) { 10 + return `${hours} hours`; 11 + } 4 12 if (minutes < 2) { 5 13 const seconds = Math.max(0, Math.round(ms / 1000)); 6 14 return `${seconds} seconds`;