From 18b17a74dd2b5d2516a35999e849dff44a466025 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 9 Jan 2026 13:30:21 -0500 Subject: [PATCH 1/4] calendar view Calendar view of friends! I left out the sample/test data for privacy reasons, but when server implementation is done, it'll be irrelevent. --- client/src/routes/calendar/+page.svelte | 1 + client/src/routes/friends/+page.svelte | 482 ++++++++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 client/src/routes/friends/+page.svelte diff --git a/client/src/routes/calendar/+page.svelte b/client/src/routes/calendar/+page.svelte index c2d0a67..c5355eb 100644 --- a/client/src/routes/calendar/+page.svelte +++ b/client/src/routes/calendar/+page.svelte @@ -972,6 +972,7 @@

+
diff --git a/client/src/routes/friends/+page.svelte b/client/src/routes/friends/+page.svelte new file mode 100644 index 0000000..f9c82b1 --- /dev/null +++ b/client/src/routes/friends/+page.svelte @@ -0,0 +1,482 @@ + + +
+
+
+
Friends:
+ {#each friendList as friend} + { + 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} +
+
+
Primary:
+ selectedFriends.includes(f.id)) + .map((f) => ({ text: f.name, value: f.id })) + ]} + bind:value={primaryUser} + /> +
+
+ {#if Object.values(stackedMeetings.byDay ?? {}).some((arr) => arr.length > 0)} + {@const latestHour = getLatestEndHourFromBlocks()} + {@const numHours = latestHour - 8 + 1} +
+
+
+
+
+ {#each Array(numHours) as _, i} + {@const hour = i + 8} +
+ {formatHourLabel(hour)} +
+ {/each} +
+ + {#each dayOrder.slice(0, 5) as day} + {@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 Array(numHours) as _} +
+ {/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} +
+ +{#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} From 003654e77185c55cd15cae8df5a88b4e40e14f23 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 12 Jan 2026 15:12:50 -0500 Subject: [PATCH 2/4] find a time to meet --- client/src/routes/friends/+page.svelte | 261 ++++++++++++++++++++----- 1 file changed, 217 insertions(+), 44 deletions(-) diff --git a/client/src/routes/friends/+page.svelte b/client/src/routes/friends/+page.svelte index f9c82b1..d2d6c57 100644 --- a/client/src/routes/friends/+page.svelte +++ b/client/src/routes/friends/+page.svelte @@ -2,7 +2,7 @@ import { processedData as storedProcessedData, userSettings as storedUserSettings } from '$lib/store'; import type { Course, MeetingTime, DayItem } from '$lib/types'; import { fade, scale } from 'svelte/transition'; - import { Chip, SelectOutlined } from 'm3-svelte'; + import { Chip, SelectOutlined, TextFieldOutlined, VariableTabs } from 'm3-svelte'; import friendSample2 from './user2.json'; import friendSample3 from './user3.json'; import friendSample4 from './user4.json'; @@ -149,6 +149,119 @@ { key: 'sunday', label: 'Sunday', abbr: 'Su', order: 6 } ]; + let meetMinDurationMinutesInput = $state('30'); + let meetBufferMinutesInput = $state('10'); + let meetMinDurationMinutes = $derived(Math.max(0, Math.floor(Number(meetMinDurationMinutesInput) || 0))); + let meetBufferMinutes = $derived(Math.max(0, Math.floor(Number(meetBufferMinutesInput) || 0))); + let meetBetweenClassesOnly = $state(false); + let meetRangeStartInput = $state('08:00'); + let meetRangeEndInput = $state('20:00'); + let meetRangeStart = $derived(Math.max(8 * 60, Math.min(20 * 60, parseTimeToMinutes(meetRangeStartInput)))); + let meetRangeEnd = $derived(Math.max(8 * 60, Math.min(20 * 60, parseTimeToMinutes(meetRangeEndInput)))); + let meetRangeValid = $derived(meetRangeEnd > meetRangeStart); + + type MeetWindow = { start: number; end: number; duration: number }; + type MeetInterval = { start: number; end: number }; + + let tab = $state<'calendar' | 'meeting'>('calendar'); + + let bestMeetTimesByDay = $derived.by(() => { + const dayStart = meetRangeStart; + const dayEnd = meetRangeEnd; + if (!meetRangeValid) return {}; + + const selectedFriendCourses = friendList + .filter((f) => selectedFriends.includes(f.id)) + .flatMap((f) => f.courses); + + const weekdays = dayOrder.slice(0, 5).map((d) => d.key); + const byDay: Record = {}; + + if (selectedFriendCourses.length === 0) { + for (const key of weekdays) byDay[key] = []; + return byDay; + } + + const meetingOccursOnDay = (meeting: MeetingTime, dayKey: DayItem['key']): boolean => { + return Boolean((meeting as unknown as Record)[dayKey]); + }; + + const collectBusyIntervals = (dayKey: DayItem['key']): MeetInterval[] => { + const intervals: MeetInterval[] = []; + for (const course of selectedFriendCourses) { + for (const meeting of course.meeting_times) { + if (!meetingOccursOnDay(meeting, dayKey)) continue; + const rawStart = parseTimeToMinutes(meeting.begin_time); + const rawEnd = parseTimeToMinutes(meeting.end_time); + if (!Number.isFinite(rawStart) || !Number.isFinite(rawEnd)) continue; + const start = Math.max(dayStart, rawStart - meetBufferMinutes); + const end = Math.min(dayEnd, rawEnd + meetBufferMinutes); + if (end <= start) continue; + intervals.push({ start, end }); + } + } + intervals.sort((a, b) => (a.start - b.start) || (a.end - b.end)); + return intervals; + }; + + const mergeIntervals = (sorted: MeetInterval[]): MeetInterval[] => { + const merged: MeetInterval[] = []; + for (const it of sorted) { + const last = merged[merged.length - 1]; + if (!last || it.start > last.end) { + merged.push({ start: it.start, end: it.end }); + continue; + } + last.end = Math.max(last.end, it.end); + } + return merged; + }; + + const windowsFromMerged = (merged: MeetInterval[]): MeetWindow[] => { + const windows: MeetWindow[] = []; + if (meetBetweenClassesOnly) { + for (let i = 0; i < merged.length - 1; i++) { + const start = merged[i].end; + const end = merged[i + 1].start; + const dur = end - start; + if (dur >= meetMinDurationMinutes) windows.push({ start, end, duration: dur }); + } + return windows; + } + + let cursor = dayStart; + for (const b of merged) { + if (b.start > cursor) { + const dur = b.start - cursor; + if (dur >= meetMinDurationMinutes) { + windows.push({ start: cursor, end: b.start, duration: dur }); + } + } + cursor = Math.max(cursor, b.end); + } + if (cursor < dayEnd) { + const dur = dayEnd - cursor; + if (dur >= meetMinDurationMinutes) { + windows.push({ start: cursor, end: dayEnd, duration: dur }); + } + } + return windows; + }; + + for (const key of weekdays) { + const merged = mergeIntervals(collectBusyIntervals(key as DayItem['key'])); + if (merged.length === 0) { + byDay[key] = []; + continue; + } + const windows = windowsFromMerged(merged); + windows.sort((a, b) => (b.duration - a.duration) || (a.start - b.start)); + byDay[key] = windows.slice(0, 3); + } + + return byDay; + }); + type PositionedMeeting = { course: Course; meeting: MeetingTime; @@ -397,59 +510,119 @@ /> - {#if Object.values(stackedMeetings.byDay ?? {}).some((arr) => arr.length > 0)} - {@const latestHour = getLatestEndHourFromBlocks()} - {@const numHours = latestHour - 8 + 1} -
-
-
-
-
- {#each Array(numHours) as _, i} - {@const hour = i + 8} -
- {formatHourLabel(hour)} +
+ +
+ + {#if tab === 'calendar'} + {#if Object.values(stackedMeetings.byDay ?? {}).some((arr) => arr.length > 0)} + {@const latestHour = getLatestEndHourFromBlocks()} + {@const numHours = latestHour - 8 + 1} +
+
+
+
+
+ {#each Array(numHours) as _, i} + {@const hour = i + 8} +
+ {formatHourLabel(hour)} +
+ {/each} +
+ + {#each dayOrder.slice(0, 5) as day} + {@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 Array(numHours) as _} +
+ {/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} - {@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} + {@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} +
- -
- {#each Array(numHours) as _} -
- {/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} -
-
+ {/if} {/each}
-
+ {:else} +
No meeting windows match your filters.
+ {/if}
- {:else} -
No calendar data available.
{/if} +
{#if activeCourse} From 49760a1e1287ddd488fd85378dda2dc1c89bdd0d Mon Sep 17 00:00:00 2001 From: Cattn Date: Thu, 12 Mar 2026 18:16:53 -0400 Subject: [PATCH 3/4] initial friends server logic copilot yap prob useful: Introduce friend-related backend calls, types and a full friends UI. API: add methods for listing friends/requests, create/accept/decline/cancel requests, remove friends, check processing status and fetch processed events. Types: add FriendIdentity, request/response shapes and export them. Friends page: replace static samples with real data flows, load friends/requests on mount, load friend schedules, map processed events into Course shapes, and add UI for sending requests, accepting/declining/canceling requests and removing friends; includes loading/error state handling and several Svelte/template typing/keying improvements. Also apply small TS/comment and navigation lint tweaks in calendar page. --- client/package-lock.json | 16 +- client/src/lib/api.ts | 116 ++++++- client/src/lib/types.ts | 47 ++- client/src/routes/calendar/+page.svelte | 5 +- client/src/routes/friends/+page.svelte | 399 ++++++++++++++++++++---- 5 files changed, 517 insertions(+), 66 deletions(-) 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 c5355eb..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; } diff --git a/client/src/routes/friends/+page.svelte b/client/src/routes/friends/+page.svelte index d2d6c57..8358e8e 100644 --- a/client/src/routes/friends/+page.svelte +++ b/client/src/routes/friends/+page.svelte @@ -1,11 +1,10 @@
-
-
Friends:
- {#each friendList as friend} - { - 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'; + {#if pageError} +
{pageError}
+ {/if} +
+
Send friend request
+
+ { + if (e.key === 'Enter') { + sendFriendRequest(); } }} + /> + +
+
+
+
Incoming requests
+ {#if requestsLoading} +
Loading requests...
+ {:else if incomingRequests.length === 0} +
No incoming requests.
+ {:else} + {#each incomingRequests as req (req.request_id)} +
+
{req.from.name} ({req.from.id})
+
+ + +
+
+ {/each} + {/if} +
+
+
Outgoing requests
+ {#if requestsLoading} +
Loading requests...
+ {:else if outgoingRequests.length === 0} +
No outgoing requests.
+ {:else} + {#each outgoingRequests as req (req.request_id)} +
+
{req.to.name} ({req.to.id})
+ +
+ {/each} + {/if} +
+
+
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}
Primary:
@@ -530,7 +802,7 @@
- {#each Array(numHours) as _, i} + {#each Array(numHours) as i (i)} {@const hour = i + 8}
{formatHourLabel(hour)} @@ -538,7 +810,7 @@ {/each}
- {#each dayOrder.slice(0, 5) as day} + {#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)} @@ -548,7 +820,7 @@
- {#each Array(numHours) as _} + {#each Array(numHours) as i (`grid-${day.key}-${i}`)}
{/each} @@ -601,7 +873,7 @@
Invalid time range.
{:else if Object.values(bestMeetTimesByDay ?? {}).some((arr) => arr.length > 0)}
- {#each dayOrder.slice(0, 5) as day} + {#each dayOrder.slice(0, 5) as day (day.key)} {@const windows = bestMeetTimesByDay?.[day.key] ?? []} {#if windows.length > 0}
@@ -649,6 +921,11 @@
+ {#if activeMeeting && activeDay} +
+ {activeDay.label}: {convertTo12Hour(activeMeeting.begin_time)} - {convertTo12Hour(activeMeeting.end_time)} +
+ {/if}
From ecc7675836b6501d51d3c7154e1288a86d785e09 Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 12 Mar 2026 19:55:14 -0400 Subject: [PATCH 4/4] pt 2 revert certain calendar view changes, fix up UI greatly, fix up displaying user info, etc. TODO: test with 3 total users, fixup placement on the homepage tab of buttons, add back button --- client/src/routes/friends/+page.svelte | 322 ++++++++++++++----------- 1 file changed, 184 insertions(+), 138 deletions(-) diff --git a/client/src/routes/friends/+page.svelte b/client/src/routes/friends/+page.svelte index 8358e8e..283b13f 100644 --- a/client/src/routes/friends/+page.svelte +++ b/client/src/routes/friends/+page.svelte @@ -32,11 +32,21 @@ let schedulesLoadVersion = 0; type RawFriendMeeting = { + id?: number | string; begin_time: string; end_time: string; - day_of_week: string; + day_of_week?: string; start_date?: string; end_date?: string; + monday?: boolean; + tuesday?: boolean; + wednesday?: boolean; + thursday?: boolean; + friday?: boolean; + saturday?: boolean; + sunday?: boolean; + color?: string; + title_overrides?: Partial>; location?: { building?: { name?: string; @@ -52,6 +62,11 @@ prefix?: string; course_number?: number | string; schedule_type?: string; + professor?: { + first_name?: string; + last_name?: string; + email?: string; + }; term?: { uid?: number | string; season?: string; @@ -93,8 +108,10 @@ return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; } - function mapFriendCourses(data: FriendProcessedEventsResponse, friendId: string): Course[] { - const processedCourses = Array.isArray(data?.processed_courses) ? (data.processed_courses as RawFriendCourse[]) : []; + function mapFriendCourses(data: FriendProcessedEventsResponse & { classes?: RawFriendCourse[] }, friendId: string): Course[] { + const processedCourses = Array.isArray(data?.processed_courses) + ? (data.processed_courses as RawFriendCourse[]) + : (Array.isArray(data?.classes) ? data.classes : []); return processedCourses.map((c, ci: number) => ({ title: c.title, prefix: c.subject ?? c.prefix ?? '', @@ -102,16 +119,25 @@ schedule_type: c.schedule_type ?? '', term: { uid: Number(c.term?.uid ?? 0), season: c.term?.season ?? '', year: Number(c.term?.year ?? 0) }, professor: { - first_name: c.instructors?.[0]?.first_name ?? '', - last_name: c.instructors?.[0]?.last_name ?? '', - email: c.instructors?.[0]?.email ?? '' + first_name: c.professor?.first_name ?? c.instructors?.[0]?.first_name ?? '', + last_name: c.professor?.last_name ?? c.instructors?.[0]?.last_name ?? '', + email: c.professor?.email ?? c.instructors?.[0]?.email ?? '' }, meeting_times: (Array.isArray(c.meeting_times) ? c.meeting_times : []).map((m, mi: number) => { const begin = to24Hour(m.begin_time); const end = to24Hour(m.end_time); const dayKey = String(m.day_of_week ?? '').toLowerCase() as DayItem['key']; + const hasDayFlags = ( + m.monday !== undefined || + m.tuesday !== undefined || + m.wednesday !== undefined || + m.thursday !== undefined || + m.friday !== undefined || + m.saturday !== undefined || + m.sunday !== undefined + ); return { - id: `${friendId}-${ci}-${mi}-${dayKey}`, + id: m.id ?? `${friendId}-${ci}-${mi}-${dayKey}`, begin_time: begin, end_time: end, start_date: m.start_date ?? '', @@ -123,13 +149,15 @@ }, room: m.location?.room ?? '' }, - monday: dayKey === 'monday', - tuesday: dayKey === 'tuesday', - wednesday: dayKey === 'wednesday', - thursday: dayKey === 'thursday', - friday: dayKey === 'friday', - saturday: dayKey === 'saturday', - sunday: dayKey === 'sunday' + monday: hasDayFlags ? Boolean(m.monday) : dayKey === 'monday', + tuesday: hasDayFlags ? Boolean(m.tuesday) : dayKey === 'tuesday', + wednesday: hasDayFlags ? Boolean(m.wednesday) : dayKey === 'wednesday', + thursday: hasDayFlags ? Boolean(m.thursday) : dayKey === 'thursday', + friday: hasDayFlags ? Boolean(m.friday) : dayKey === 'friday', + saturday: hasDayFlags ? Boolean(m.saturday) : dayKey === 'saturday', + sunday: hasDayFlags ? Boolean(m.sunday) : dayKey === 'sunday', + color: m.color, + title_overrides: m.title_overrides }; }) })); @@ -523,13 +551,17 @@ try { const coursesByFriend = await Promise.all(friendIdentities.map(async (friend) => { try { - const status = await API.friendIsProcessed(friend.id, termUid); - if (!status.processed) { - return { id: friend.id, courses: [] as Course[] }; - } const response = await API.getFriendProcessedEvents(friend.id, termUid); return { id: friend.id, courses: mapFriendCourses(response, friend.id) }; } catch (error) { + try { + const status = await API.friendIsProcessed(friend.id, termUid); + if (!status.processed) { + return { id: friend.id, courses: [] as Course[] }; + } + } catch (statusError) { + console.error(`Failed to check processed status for ${friend.id}`, statusError); + } console.error(`Failed to load schedule for ${friend.id}`, error); return { id: friend.id, courses: [] as Course[] }; } @@ -655,131 +687,144 @@ }); -
-
+
+
{#if pageError}
{pageError}
{/if} -
-
Send friend request
-
- { - if (e.key === 'Enter') { - sendFriendRequest(); - } - }} - /> - +
+
+
Send friend request
+
+ { + if (e.key === 'Enter') { + sendFriendRequest(); + } + }} + /> + +
-
-
-
Incoming requests
- {#if requestsLoading} -
Loading requests...
- {:else if incomingRequests.length === 0} -
No incoming requests.
- {:else} - {#each incomingRequests as req (req.request_id)} -
-
{req.from.name} ({req.from.id})
-
- - + + {#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}
-
- {/each} - {/if} -
-
-
Outgoing requests
- {#if requestsLoading} -
Loading requests...
- {:else if outgoingRequests.length === 0} -
No outgoing requests.
- {:else} - {#each outgoingRequests as req (req.request_id)} -
-
{req.to.name} ({req.to.id})
- -
- {/each} + {/if} +
{/if} -
-
-
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} - - + +
+
+
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}
- {/each} - {/if} -
-
-
Primary:
- selectedFriends.includes(f.id)) - .map((f) => ({ text: f.name, value: f.id })) - ]} - bind:value={primaryUser} - /> + {/if} +
@@ -797,12 +842,13 @@ {#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 Array(numHours) as i (i)} + {#each hourIndices as i (i)} {@const hour = i + 8}
{formatHourLabel(hour)} @@ -820,7 +866,7 @@
- {#each Array(numHours) as i (`grid-${day.key}-${i}`)} + {#each hourIndices as i (`grid-${day.key}-${i}`)}
{/each}