Files
drp-48/src/routes/+page.svelte
2025-06-16 21:31:34 +01:00

330 lines
11 KiB
Svelte

<script lang="ts">
import SpaceCard from "$lib/components/SpaceCard.svelte";
import defaultImg from "$lib/assets/study_space.png";
import crossUrl from "$lib/assets/cross.svg";
import searchUrl from "$lib/assets/search.svg";
import Navbar from "$lib/components/Navbar.svelte";
import { collectTimings, timeToMins, haversineDistance } from "$lib";
import Button from "$lib/components/Button.svelte";
import { urldecodeSortFilter } from "$lib/filter.js";
import { invalidateAll } from "$app/navigation";
import type { Table } from "$lib";
const { data } = $props();
const {
studySpaces,
supabase,
session,
adminMode,
searchParams,
favouriteIds: initialFavourites = []
} = $derived(data);
let favouriteIds = $derived<string[]>(initialFavourites);
let showFavourites = $state(false);
const sortFilter = $derived(urldecodeSortFilter(searchParams));
const selectedTags = $derived(sortFilter.tags ?? []);
const openingFilter = $derived(sortFilter.openAt?.from);
const closingFilter = $derived(sortFilter.openAt?.to);
const sortNear = $derived(sortFilter.nearby);
// Toggle a space in/out of favourites
async function handleToggleFavourite(id: string) {
if (!session?.user) return;
const already = favouriteIds.includes(id);
if (already) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: id });
favouriteIds = favouriteIds.filter((x) => x !== id);
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: id }]);
favouriteIds = [...favouriteIds, id];
}
}
// Combine tag and time filtering
let filteredStudySpaces = $derived(
studySpaces
// only include favourites when showFavourites===true
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
// tag filtering
.filter((space) => {
if (selectedTags.length === 0) return true;
const allTags = [
...(space.tags || []),
space.volume,
space.wifi,
space.power
].filter(Boolean);
return selectedTags.every((tag) => allTags.includes(tag));
})
// opening time filter
.filter((space) => {
if (!openingFilter) return true;
const entry = space.study_space_hours?.find(
(h) => h.day_of_week === new Date().getDay()
);
if (!entry) return false;
if (entry.open_today_status) return true;
const openMin = timeToMins(entry.opens_at);
let closeMin = timeToMins(entry.closes_at);
// Treat midnight as end of day and handle overnight spans
if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin = timeToMins(openingFilter);
// Include spaces open at the filter time
return filterMin >= openMin && filterMin < closeMin;
})
// closing time filter
.filter((space) => {
if (!closingFilter) return true;
const entry = space.study_space_hours?.find(
(h) => h.day_of_week === new Date().getDay()
);
if (!entry) return false;
if (entry.open_today_status) return true;
const openMin = timeToMins(entry.opens_at);
let closeMin = timeToMins(entry.closes_at);
if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin =
timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
// Include spaces still open at the filter time
return filterMin > openMin && filterMin <= closeMin;
})
);
const sortedByOpenNow = $derived(
filteredStudySpaces.toSorted((a, b) => {
const now = new Date();
const time = now.toTimeString().slice(0, 5);
const today = now.getDay();
let openUntil = [0, 0] as number[];
for (const [index, day] of [a, b].entries()) {
const timingsPerDay = collectTimings(day.study_space_hours);
for (const timing of timingsPerDay[today]) {
if (timing.open_today_status === true) {
openUntil[index] = 24 * 60;
break;
} else if (timing.open_today_status === false) {
break;
} else {
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
if (opensFor) {
openUntil[index] = opensFor;
break;
}
}
}
}
return openUntil[1] - openUntil[0];
})
);
const sortedStudySpaces = $derived(
sortNear
? filteredStudySpaces.toSorted((a, b) => {
if (!sortNear) return 0;
type DBLatLng = { lat: number; lng: number } | undefined;
const aLoc = a.building_location as unknown as google.maps.places.PlaceResult;
const bLoc = b.building_location as unknown as google.maps.places.PlaceResult;
const aLatLng = aLoc.geometry?.location as DBLatLng;
const bLatLng = bLoc.geometry?.location as DBLatLng;
const aDistance = haversineDistance(
sortNear.lat,
sortNear.lng,
aLatLng?.lat || sortNear.lat,
aLatLng?.lng || sortNear.lng
);
const bDistance = haversineDistance(
sortNear.lat,
sortNear.lng,
bLatLng?.lat || sortNear.lat,
bLatLng?.lng || sortNear.lng
);
return aDistance - bDistance;
})
: sortedByOpenNow
);
// Open now
function isOpenNow(all_study_space_hours: Table<"study_space_hours">[]) {
const now = new Date();
const time = now.toTimeString().slice(0, 5);
const day = now.getDay();
const timingsPerDay = collectTimings(all_study_space_hours);
for (const timing of timingsPerDay[day]) {
if (timing.open_today_status === true) {
return { isOpen: true, message: `Open all day` };
} else if (timing.open_today_status === false) {
return { isOpen: false, message: `Closed today` };
} else {
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
if (opensFor) {
return {
isOpen: true,
message: `Open now for: ${minsToReadableHours(opensFor)}`
};
}
}
}
return { isOpen: false, message: "Closed right now" };
}
function timeUntilClosing(openingTime: string, closingTime: string, currentTime: string) {
const currTimeInMins = timeToMins(currentTime);
const OpeningTimeInMins = timeToMins(openingTime);
const closingTimeInMins = timeToMins(closingTime);
if (currTimeInMins >= OpeningTimeInMins && currTimeInMins < closingTimeInMins) {
return closingTimeInMins - currTimeInMins;
}
}
function minsToReadableHours(mins: number) {
return `${Math.floor(mins / 60)} hrs, ${mins % 60} mins`;
}
</script>
<Navbar>
{#if session}
<a href="/space/new/edit">
<img src={crossUrl} alt="new" class="new-space" />
</a>
{/if}
{#if adminMode}
<span class="checkReports">
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
</span>
{/if}
{#if session}
<button class="fav-button" onclick={() => (showFavourites = !showFavourites)} type="button">
{showFavourites ? "All spaces" : "My favourites"}
</button>
{/if}
<div class="filterWrapper">
<Button type="link" href="/filter?{searchParams}">
<span class="search">
<img src={searchUrl} alt="search" />
Search
</span>
</Button>
</div>
</Navbar>
<main>
{#each sortedStudySpaces as studySpace (studySpace.id)}
{@const imgUrl =
studySpace.study_space_images.length > 0
? supabase.storage
.from("files_bucket")
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
: defaultImg}
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={imgUrl}
space={studySpace}
isFavourite={favouriteIds.includes(studySpace.id)}
onToggleFavourite={session ? () => handleToggleFavourite(studySpace.id) : undefined}
isAvailable={studySpace.study_space_hours.length === 0
? undefined
: isOpenNow(studySpace.study_space_hours).isOpen}
footer={studySpace.study_space_hours.length === 0
? undefined
: isOpenNow(studySpace.study_space_hours).message}
/>
{/each}
</main>
<footer>
{#if session}
<Button
onclick={async () => {
await supabase.auth.signOut();
invalidateAll();
}}>Signout</Button
>
{:else}
<Button href="/auth" type="link">Login / Signup</Button>
{/if}
</footer>
<style>
main {
display: grid;
box-sizing: border-box;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.5rem;
max-width: 32rem;
width: 100%;
margin: 0 auto;
}
footer {
display: flex;
flex-direction: column;
max-width: 600px;
width: 100%;
padding: 1rem;
margin: 0 auto;
}
.new-space {
transform: rotate(45deg);
}
.checkReports {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
font-size: 1rem;
margin-left: 0.5rem;
}
.fav-button {
background: none;
border: none;
color: #eaffeb;
font-size: 1rem;
cursor: pointer;
}
.fav-button:hover {
text-decoration: underline;
}
.filterWrapper {
display: flex;
justify-content: center;
align-items: center;
margin-right: 0.5rem;
}
.search {
display: flex;
align-items: center;
gap: 0.3rem;
color: #eaffeb;
font-size: 1rem;
}
.search img {
width: 1.2rem;
height: 1.2rem;
}
@media (max-width: 20rem) {
main {
grid-template-columns: 1fr;
}
}
</style>