From 18b17a74dd2b5d2516a35999e849dff44a466025 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 9 Jan 2026 13:30:21 -0500 Subject: [PATCH 1/2] 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/2] 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}