diff --git a/client/package-lock.json b/client/package-lock.json index b570b70..e7be883 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1180,6 +1180,7 @@ "integrity": "sha512-GAAbkWrbRJvysL7+HOWs5v/+TmnRcEQPeED2sUcDFTHpPvRYADEtScL6x8hWuKp0DKauJVaVJLTjQVy9e7cMiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1219,6 +1220,7 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1594,6 +1596,7 @@ "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1644,6 +1647,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1861,6 +1865,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2211,6 +2216,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3326,6 +3332,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3353,6 +3360,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3486,6 +3494,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3502,6 +3511,7 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -3838,6 +3848,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz", "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3931,7 +3942,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -4019,6 +4031,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4081,6 +4094,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 3c12c2b..0c00ca3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,5 +1,5 @@ import { EnvironmentManager } from "./environment"; -import type { isProcessed, ProcessedEvents, UniversityCalendarEvent, UniversityEventCategoryWithCount, UserSettings } from "./types"; +import type { FriendListResponse, FriendProcessedEventsResponse, FriendRequestAcceptResponse, FriendRequestCreateResponse, FriendRequestsResponse, isProcessed, OkResponse, ProcessedEvents, UniversityCalendarEvent, UniversityEventCategoryWithCount, UserSettings } from "./types"; export class API { private static async getBaseUrl(): Promise { @@ -108,6 +108,120 @@ export class API { return response.json(); } + public static async getFriends(): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async getFriendRequests(): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/requests`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async createFriendRequest(friendId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/requests`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ friend_id: friendId }) + }); + return response.json(); + } + + public static async acceptFriendRequest(requestId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/requests/${requestId}/accept`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async declineFriendRequest(requestId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/requests/${requestId}/decline`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async cancelFriendRequest(requestId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/requests/${requestId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async removeFriend(friendId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/${friendId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async friendIsProcessed(friendId: string, termUid: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/${friendId}/is_processed`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ term_uid: termUid }) + }); + return response.json(); + } + + public static async getFriendProcessedEvents(friendId: string, termUid: string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/friends/${friendId}/processed_events`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ term_uid: termUid }) + }); + return response.json(); + } + public static async getIcsUrl(): Promise<{ ics_url: string }> { const baseUrl = await this.getBaseUrl(); const token = await this.getJwtToken(); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index bd16839..cf47aee 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -62,6 +62,49 @@ interface isProcessed { processed: boolean; } +interface FriendIdentity { + id: string; + name: string; +} + +interface FriendRequestIncoming { + request_id: string; + from: FriendIdentity; + created_at: string; +} + +interface FriendRequestOutgoing { + request_id: string; + to: FriendIdentity; + created_at: string; +} + +interface FriendListResponse { + friends: FriendIdentity[]; +} + +interface FriendRequestsResponse { + incoming: FriendRequestIncoming[]; + outgoing: FriendRequestOutgoing[]; +} + +interface FriendRequestCreateResponse { + request_id: string; +} + +interface FriendRequestAcceptResponse { + friendship_id: string; + friend: FriendIdentity; +} + +interface OkResponse { + ok: boolean; +} + +interface FriendProcessedEventsResponse { + processed_courses: any[]; +} + interface Professor { first_name: string; last_name: string; @@ -224,6 +267,6 @@ export { FEATURE_FLAGS, type Building, type CalendarConfig, type Course, type CurrentTerm, type DayItem, - type EventPreferences, type GetPreferencesResponse, type isProcessed, type Location, - type MeetingTime, type NextTerm, type NotificationMethod, type NotificationSetting, type NotificationType, type Preview, type ProcessedEvents, type Professor, type ReminderSettings, type ResolvedData, type ResponseData, type TemplateVariables, type Term, type TermResponse, type UniversityCalendarEvent, type UniversityEventCategory, type UniversityEventCategoryWithCount, type UserSettings + type EventPreferences, type FriendIdentity, type FriendListResponse, type FriendProcessedEventsResponse, type FriendRequestAcceptResponse, type FriendRequestCreateResponse, type FriendRequestIncoming, type FriendRequestOutgoing, type FriendRequestsResponse, type GetPreferencesResponse, type isProcessed, type Location, + type MeetingTime, type NextTerm, type NotificationMethod, type NotificationSetting, type NotificationType, type OkResponse, type Preview, type ProcessedEvents, type Professor, type ReminderSettings, type ResolvedData, type ResponseData, type TemplateVariables, type Term, type TermResponse, type UniversityCalendarEvent, type UniversityEventCategory, type UniversityEventCategoryWithCount, type UserSettings }; diff --git a/client/src/routes/calendar/+page.svelte b/client/src/routes/calendar/+page.svelte index c2d0a67..6827274 100644 --- a/client/src/routes/calendar/+page.svelte +++ b/client/src/routes/calendar/+page.svelte @@ -668,7 +668,8 @@ event_preference.color_id = courseColor; } - //@ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-expect-error const convertedNotifications: ReminderSettings[] = notifications.map(n => ({ time: (n.time).toString(), type: n.type, @@ -838,6 +839,7 @@ jwt_token = await API.getJwtToken(); if (!jwt_token) { // No JWT token for current environment, redirect to welcome page + // eslint-disable-next-line svelte/no-navigation-without-resolve goto('/'); return; } @@ -859,6 +861,7 @@ jwt_token = await API.getJwtToken(); if (!jwt_token) { // No JWT token for current environment, redirect to welcome page + // eslint-disable-next-line svelte/no-navigation-without-resolve goto('/'); return; } @@ -972,6 +975,7 @@

+
diff --git a/client/src/routes/friends/+page.svelte b/client/src/routes/friends/+page.svelte new file mode 100644 index 0000000..283b13f --- /dev/null +++ b/client/src/routes/friends/+page.svelte @@ -0,0 +1,978 @@ + + +
+
+ {#if pageError} +
{pageError}
+ {/if} +
+
+
Send friend request
+
+ { + if (e.key === 'Enter') { + sendFriendRequest(); + } + }} + /> + +
+
+ + {#if requestsLoading || incomingRequests.length > 0 || outgoingRequests.length > 0} +
+
+
Requests
+ {#if requestsLoading} +
Loading requests...
+ {:else} +
+ {#if incomingRequests.length > 0} +
+
Incoming
+ {#each incomingRequests as req (req.request_id)} +
+
{req.from.name} ({req.from.id})
+
+ + +
+
+ {/each} +
+ {/if} + + {#if outgoingRequests.length > 0} +
+
Outgoing
+ {#each outgoingRequests as req (req.request_id)} +
+
{req.to.name} ({req.to.id})
+ +
+ {/each} +
+ {/if} +
+ {/if} +
+ {/if} + +
+
+
Primary
+ selectedFriends.includes(f.id)) + .map((f) => ({ text: f.name, value: f.id })) + ]} + bind:value={primaryUser} + /> +
+ +
+
Friends
+ {#if friendsLoading || schedulesLoading} +
Loading friends...
+ {:else if friendList.length === 0} +
No accepted friends yet.
+ {:else} +
+ {#each friendList as friend (friend.id)} +
+ { + const exists = selectedFriends.includes(friend.id); + const next = exists + ? selectedFriends.filter((id) => id !== friend.id) + : [...selectedFriends, friend.id]; + selectedFriends = next; + if (primaryUser !== 'you' && !next.includes(primaryUser)) { + primaryUser = 'you'; + } + }} + > + {friend.name} + + +
+ {/each} +
+ {/if} +
+
+
+
+ +
+ + {#if tab === 'calendar'} + {#if Object.values(stackedMeetings.byDay ?? {}).some((arr) => arr.length > 0)} + {@const latestHour = getLatestEndHourFromBlocks()} + {@const numHours = latestHour - 8 + 1} + {@const hourIndices = Array.from({ length: numHours }, (_, i) => i)} +
+
+
+
+
+ {#each hourIndices as i (i)} + {@const hour = i + 8} +
+ {formatHourLabel(hour)} +
+ {/each} +
+ + {#each dayOrder.slice(0, 5) as day (day.key)} + {@const dayEvents = stackedMeetings.byDay?.[day.key] ?? []} + {@const dayStacks = Math.max(stackedMeetings.maxStacksByDay?.[day.key] ?? 1, 1)} + {@const dayHeight = Math.min(Math.max(120, dayStacks * 52), 190)} +
+
+ {day.label} +
+ +
+ {#each hourIndices as i (`grid-${day.key}-${i}`)} +
+ {/each} + + {#each dayEvents as item (`${item.ownerId}-${item.meeting.id}`)} + {@const overlapCount = Math.max(item.overlapCount ?? 1, 1)} + {@const heightPct = Math.max((100 - (overlapCount + 1) * stackGapPct) / overlapCount, 0)} + {@const topPct = stackGapPct + item.stackIndex * (heightPct + stackGapPct)} + + {/each} +
+
+ {/each} +
+
+
+ {:else} +
No calendar data available.
+ {/if} + {:else} +
+
+
+
Best times to meet
+
Common free time for selected friends, between 8am–8pm.
+
+
+ + + + + { meetBetweenClassesOnly = !meetBetweenClassesOnly; }}> + Between classes + +
+
+
+ +
+ {#if selectedFriends.length === 0} +
Select at least one friend to see suggestions.
+ {:else if !meetRangeValid} +
Invalid time range.
+ {:else if Object.values(bestMeetTimesByDay ?? {}).some((arr) => arr.length > 0)} +
+ {#each dayOrder.slice(0, 5) as day (day.key)} + {@const windows = bestMeetTimesByDay?.[day.key] ?? []} + {#if windows.length > 0} +
+
{day.label}
+
+ {#each windows as w (`${day.key}-${w.start}-${w.end}`)} + {}}> + {convertTo12Hour(minutesToHHMM(w.start))} – {convertTo12Hour(minutesToHHMM(w.end))} ({w.duration}m) + + {/each} +
+
+ {/if} + {/each} +
+ {:else} +
No meeting windows match your filters.
+ {/if} +
+ {/if} + +
+ +{#if activeCourse} +
{ if (e.target === e.currentTarget) { activeCourse = undefined; activeMeeting = undefined; activeDay = undefined; } }} + onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { activeCourse = undefined; activeMeeting = undefined; activeDay = undefined; } }} + > +
+
+
+

{activeCourse.title}

+ +
+ {#if activeMeeting && activeDay} +
+ {activeDay.label}: {convertTo12Hour(activeMeeting.begin_time)} - {convertTo12Hour(activeMeeting.end_time)} +
+ {/if} +
+
+
+{/if}