This repository has no description
0

Configure Feed

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

feat: add users table

+184 -89
+49 -4
src/features/api/routes/recentTakes.ts
··· 1 1 import { eq, desc, and, or } from "drizzle-orm"; 2 2 import { db } from "../../../libs/db"; 3 - import { takes as takesTable } from "../../../libs/schema"; 3 + import { takes as takesTable, users as usersTable } from "../../../libs/schema"; 4 4 import { handleApiError } from "../../../libs/apiError"; 5 5 6 6 export type RecentTake = { ··· 10 10 createdAt: Date; 11 11 mediaUrls: string[]; 12 12 elapsedTimeMs: number; 13 + project: string; 14 + totalTakesTime: number; 13 15 }; 14 16 15 17 export async function recentTakes(url: URL): Promise<Response> { 16 18 try { 17 19 const userId = url.searchParams.get("user"); 18 20 19 - const query = db 21 + if (userId) { 22 + // Verify user exists if userId provided 23 + const user = await db 24 + .select() 25 + .from(usersTable) 26 + .where(eq(usersTable.id, userId)) 27 + .limit(1); 28 + 29 + if (user.length === 0) { 30 + return new Response( 31 + JSON.stringify({ 32 + error: "User not found", 33 + takes: [], 34 + }), 35 + { 36 + status: 404, 37 + headers: { 38 + "Content-Type": "application/json", 39 + }, 40 + }, 41 + ); 42 + } 43 + } 44 + 45 + const recentTakes = await db 20 46 .select() 21 47 .from(takesTable) 22 48 .orderBy(desc(takesTable.createdAt)) 23 49 .where(eq(takesTable.userId, userId ? userId : takesTable.userId)) 24 50 .limit(40); 25 - 26 - const recentTakes = await query; 27 51 28 52 if (recentTakes.length === 0) { 29 53 return new Response( ··· 38 62 ); 39 63 } 40 64 65 + // Get unique user IDs 66 + const userIds = [...new Set(recentTakes.map((take) => take.userId))]; 67 + 68 + // Query users from takes table 69 + const users = await db 70 + .select() 71 + .from(usersTable) 72 + .where(or(...userIds.map((id) => eq(usersTable.id, id)))); 73 + 74 + // Create map of user data by ID 75 + const userMap = users.reduce( 76 + (acc, user) => { 77 + acc[user.id] = user; 78 + return acc; 79 + }, 80 + {} as Record<string, (typeof users)[number]>, 81 + ); 82 + 41 83 const takes: RecentTake[] = 42 84 recentTakes.map((take) => ({ 43 85 id: take.id, ··· 46 88 createdAt: new Date(take.createdAt), 47 89 mediaUrls: take.media ? JSON.parse(take.media) : [], 48 90 elapsedTimeMs: take.elapsedTimeMs, 91 + project: userMap[take.userId]?.projectName || "unknown project", 92 + totalTakesTime: 93 + userMap[take.userId]?.totalTakesTime || take.elapsedTimeMs, 49 94 })) || []; 50 95 51 96 return new Response(
+105 -83
src/features/frontend/app.tsx
··· 34 34 35 35 useEffect(() => { 36 36 async function getTakes() { 37 - const res = await fetch("/api/recentTakes"); 38 - const data = await res.json(); 39 - 40 - console.log(data); 41 - setTakes(data.takes); 37 + try { 38 + const res = await fetch("/api/recentTakes"); 39 + if (!res.ok) { 40 + throw new Error(`HTTP error! status: ${res.status}`); 41 + } 42 + const data = await res.json(); 43 + setTakes(data.takes); 44 + } catch (error) { 45 + console.error("Error fetching takes:", error); 46 + setTakes([]); 47 + } 42 48 } 43 49 getTakes(); 44 50 }, []); ··· 53 59 return ( 54 60 <div className="container"> 55 61 <h1 className="title">Recent Takes</h1> 56 - <Masonry 57 - breakpointCols={breakpointColumns} 58 - className="takes-grid" 59 - columnClassName="takes-grid-column" 60 - > 61 - {takes.map((take) => ( 62 - <div key={take.id} className="take-card"> 63 - <div className="take-header"> 64 - <h2 className="take-title">{take.notes}</h2> 65 - <div className="user-pill"> 66 - <div className="user-info"> 67 - <img 68 - src={userData[take.userId]?.imageUrl} 69 - alt="Profile" 70 - className="profile-image" 71 - /> 72 - <span className="user-name"> 73 - {userData[take.userId]?.displayName ?? 74 - take.userId} 75 - </span> 62 + {takes.length === 0 ? ( 63 + <div className="no-takes-message">No takes found</div> 64 + ) : ( 65 + <Masonry 66 + breakpointCols={breakpointColumns} 67 + className="takes-grid" 68 + columnClassName="takes-grid-column" 69 + > 70 + {takes.map((take) => ( 71 + <div key={take.id} className="take-card"> 72 + <div className="take-header"> 73 + <h2 className="take-title">{take.project}</h2> 74 + <div className="user-pill"> 75 + <div className="user-info"> 76 + <img 77 + src={ 78 + userData[take.userId]?.imageUrl 79 + } 80 + alt="Profile" 81 + className="profile-image" 82 + /> 83 + <span className="user-name"> 84 + {userData[take.userId] 85 + ?.displayName ?? take.userId} 86 + </span> 87 + </div> 76 88 </div> 77 89 </div> 78 - </div> 79 90 80 - <div className="take-meta"> 81 - <div className="meta-item"> 82 - <span className="meta-label">Completed:</span> 83 - <span className="meta-value"> 84 - {new Date(take.createdAt).toLocaleString()} 85 - </span> 86 - </div> 87 - <div className="meta-item"> 88 - <span className="meta-label">Duration:</span> 89 - <span className="meta-value"> 90 - {prettyPrintTime(take.elapsedTimeMs)} 91 - </span> 91 + <div className="take-meta"> 92 + <div className="meta-item"> 93 + <span className="meta-label"> 94 + Completed: 95 + </span> 96 + <span className="meta-value"> 97 + {new Date( 98 + take.createdAt, 99 + ).toLocaleString()} 100 + </span> 101 + </div> 102 + <div className="meta-item"> 103 + <span className="meta-label"> 104 + Duration: 105 + </span> 106 + <span className="meta-value"> 107 + {prettyPrintTime(take.elapsedTimeMs)} 108 + </span> 109 + </div> 92 110 </div> 93 - </div> 94 111 95 - {take.mediaUrls?.map((url: string, index: number) => { 96 - // More robust video detection for Slack-style URLs 97 - const isVideo = 98 - /\.(mp4|mov|webm|ogg)/i.test(url) || 99 - (url.includes("files.slack.com") && 100 - url.includes("download")); 101 - const contentType = isVideo ? "video" : "image"; 112 + {take.mediaUrls?.map( 113 + (url: string, index: number) => { 114 + // More robust video detection for Slack-style URLs 115 + const isVideo = 116 + /\.(mp4|mov|webm|ogg)/i.test(url) || 117 + (url.includes("files.slack.com") && 118 + url.includes("download")); 119 + const contentType = isVideo 120 + ? "video" 121 + : "image"; 102 122 103 - return ( 104 - <div 105 - key={`media-${take.id}-${index}`} 106 - className={`${contentType}-container`} 107 - > 108 - {isVideo ? ( 109 - <video 110 - controls 111 - className="take-video" 112 - preload="metadata" 113 - playsInline 123 + return ( 124 + <div 125 + key={`media-${take.id}-${index}`} 126 + className={`${contentType}-container`} 114 127 > 115 - <source 116 - src={url} 117 - type="video/mp4" 118 - /> 119 - <track 120 - kind="captions" 121 - src="" 122 - label="Captions" 123 - /> 124 - Your browser does not support the 125 - video tag. 126 - </video> 127 - ) : ( 128 - <img 129 - src={url} 130 - alt={`Media content ${index + 1}`} 131 - className="take-image" 132 - loading="lazy" 133 - /> 134 - )} 135 - </div> 136 - ); 137 - })} 138 - </div> 139 - ))} 140 - </Masonry> 128 + {isVideo ? ( 129 + <video 130 + controls 131 + className="take-video" 132 + preload="metadata" 133 + playsInline 134 + > 135 + <source 136 + src={url} 137 + type="video/mp4" 138 + /> 139 + <track 140 + kind="captions" 141 + src="" 142 + label="Captions" 143 + /> 144 + Your browser does not 145 + support the video tag. 146 + </video> 147 + ) : ( 148 + <img 149 + src={url} 150 + alt={`Media content ${index + 1}`} 151 + className="take-image" 152 + loading="lazy" 153 + /> 154 + )} 155 + </div> 156 + ); 157 + }, 158 + )} 159 + </div> 160 + ))} 161 + </Masonry> 162 + )} 141 163 </div> 142 164 ); 143 165 }
+12
src/features/frontend/styles.css
··· 14 14 max-width: 1200px; 15 15 margin: 0 auto; 16 16 padding: 2rem; 17 + /* Add these properties for vertical centering when there are no takes */ 18 + min-height: calc(100vh - 40px); /* 40px accounts for the body padding */ 19 + display: flex; 20 + flex-direction: column; 21 + } 22 + 23 + .no-takes-message { 24 + text-align: center; 25 + font-size: 1.5rem; 26 + padding: 2rem; 27 + margin: auto; /* This will center vertically when parent is flex */ 28 + max-width: 600px; 17 29 } 18 30 19 31 .title {
+16 -1
src/features/takes/services/upload.ts
··· 1 + import { eq } from "drizzle-orm"; 1 2 import { slackApp, slackClient } from "../../../index"; 2 3 import { db } from "../../../libs/db"; 3 - import { takes as takesTable } from "../../../libs/schema"; 4 + import { takes as takesTable, users as usersTable } from "../../../libs/schema"; 4 5 import * as Sentry from "@sentry/bun"; 5 6 6 7 export default async function upload() { ··· 14 15 payload.channel !== process.env.SLACK_LISTEN_CHANNEL 15 16 ) 16 17 return; 18 + 19 + const userInDB = await db 20 + .select() 21 + .from(usersTable) 22 + .where(eq(usersTable.id, user)); 23 + 24 + if (userInDB.length === 0) { 25 + await slackClient.chat.postMessage({ 26 + channel: payload.channel, 27 + thread_ts: payload.ts, 28 + text: "we don't have a project for you; set one up in the web ui or by running `/takes`", 29 + }); 30 + return; 31 + } 17 32 18 33 // Convert Slack formatting to markdown 19 34 const replaceUserMentions = async (text: string) => {
+2 -1
src/libs/schema.ts
··· 1 - import { pgTable, text, integer } from "drizzle-orm/pg-core"; 1 + import { pgTable, text, integer, boolean } from "drizzle-orm/pg-core"; 2 2 import type { Pool } from "pg"; 3 3 4 4 // Define the takes table ··· 21 21 hackatimeKeys: text("hackatime_keys").notNull().default("[]"), 22 22 projectName: text("project_name").notNull().default(""), 23 23 projectDescription: text("project_description").notNull().default(""), 24 + usingHackatimeV2: boolean().notNull().default(true), 24 25 }); 25 26 26 27 export async function setupTriggers(pool: Pool) {