Skip to content
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
NODE_ENV=production
PORT=3000

#Nixpacks chooses node 18 but next.js needs node 20.9+.
NIXPACKS_NODE_VERSION=22


NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
Expand All @@ -8,3 +15,4 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
STUDY_REMINDER_CRON_SECRET=
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
TEST
# MAC Study

MAC Study is a mobile-first PWA for Monash Association of Coding students to
Expand Down Expand Up @@ -73,6 +74,20 @@ NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com
The signing JWK is server-only. Never prefix it with `NEXT_PUBLIC_`, expose it
to browser code, or commit it to Git.

### Study reminder scheduler

Study reminders are claimed atomically by `claim_due_study_reminders` and sent
by `POST /api/study-reminders/run`. After applying
`20260805010000_study_session_reminders.sql`, configure a once-per-minute
Supabase Cron or Dokploy job to call that endpoint with:

```text
Authorization: Bearer <STUDY_REMINDER_CRON_SECRET>
```

The scheduler secret is server-only. Do not add it to a migration or browser
environment variable.

## Scripts

```bash
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export default async function ProfilePage() {
return (
<ProfileDashboard
displayName={displayName}
initialDiscoverable={profile?.is_discoverable ?? true}
userId={profile?.id ?? null}
username={profile?.username ?? null}
/>
);
Expand Down
78 changes: 78 additions & 0 deletions src/app/api/study-reminders/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { sendWebPush } from "@/lib/push/send-web-push";
import { createSupabaseAdminClient } from "@/lib/supabase/server";

export const runtime = "nodejs";

type DueStudyReminder = {
reminder_interval_minutes: number;
session_id: string;
started_at: string;
user_id: string;
};

export async function POST(request: Request) {
if (!isAuthorized(request)) {
return NextResponse.json({ message: "Unauthorized." }, { status: 401 });
}

const admin = createSupabaseAdminClient();
if (!admin) {
return NextResponse.json(
{ message: "Supabase is not configured." },
{ status: 503 },
);
}

const { data, error } = await admin.rpc("claim_due_study_reminders", {
batch_size: 100,
});

if (error) {
return NextResponse.json({ message: error.message }, { status: 500 });
}

const reminders = (data ?? []) as DueStudyReminder[];
const deliveries = await Promise.allSettled(
reminders.map((reminder) =>
sendWebPush({
body: `Your timer has been running for ${formatElapsed(reminder.started_at)}. Tap to check in.`,
category: "study_reminder",
tag: `mac-study-reminder-${reminder.session_id}`,
title: "Still studying?",
url: "/app?study-reminder=check",
userId: reminder.user_id,
}),
),
);
const delivered = deliveries.reduce(
(count, delivery) =>
count + (delivery.status === "fulfilled" ? delivery.value.sent : 0),
0,
);

return NextResponse.json({
claimed: reminders.length,
delivered,
});
}

function isAuthorized(request: Request) {
const secret = process.env.STUDY_REMINDER_CRON_SECRET;
if (!secret) return false;

return request.headers.get("authorization") === `Bearer ${secret}`;
}

function formatElapsed(startedAt: string) {
const elapsedMinutes = Math.max(
1,
Math.floor((Date.now() - new Date(startedAt).getTime()) / 60_000),
);

if (elapsedMinutes < 60) return `${elapsedMinutes} minutes`;

const hours = Math.floor(elapsedMinutes / 60);
const minutes = elapsedMinutes % 60;
return minutes ? `${hours} hr ${minutes} min` : `${hours} hr`;
}
1 change: 1 addition & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const viewport: Viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
interactiveWidget: "resizes-content",
themeColor: "#171717",
};

Expand Down
11 changes: 8 additions & 3 deletions src/components/app-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { X } from "lucide-react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";

const FOCUSABLE_SELECTOR = [
Expand Down Expand Up @@ -141,19 +142,19 @@ export function AppDialog({
};
}, [closeImmediately]);

return (
const dialog = (
<div
aria-labelledby={titleId}
aria-modal="true"
className="fixed inset-x-0 top-0 z-50 flex h-[var(--app-viewport-height)] items-center justify-center bg-black/58 px-3 pb-[calc(var(--mobile-nav-height)+0.75rem)] pt-[calc(var(--safe-area-top)+0.75rem)] backdrop-blur-sm lg:pb-[max(0.75rem,var(--safe-area-bottom))]"
className="fixed inset-x-0 top-0 z-50 flex h-[var(--app-viewport-height)] min-h-0 items-center justify-center overflow-hidden bg-black/58 px-3 pb-[calc(var(--mobile-nav-height)+0.75rem)] pt-[calc(var(--safe-area-top)+0.75rem)] backdrop-blur-sm lg:pb-[max(0.75rem,var(--safe-area-bottom))]"
onMouseDown={(event) => {
if (event.target === event.currentTarget) requestBackdropClose();
}}
role="dialog"
>
<div
className={cn(
"relative flex max-h-[min(88dvh,720px)] w-full flex-col overflow-hidden shadow-2xl",
"relative flex max-h-full w-full flex-col overflow-hidden shadow-2xl lg:max-h-[min(88dvh,720px)]",
variant === "confirmation"
? "rounded-lg bg-[var(--color-background)]"
: "rounded-2xl border border-[var(--color-border)] bg-[var(--color-background)]",
Expand Down Expand Up @@ -249,6 +250,10 @@ export function AppDialog({
</div>
</div>
);

return typeof document === "undefined"
? null
: createPortal(dialog, document.body);
}

function getFocusableElements(root: HTMLElement | null) {
Expand Down
108 changes: 93 additions & 15 deletions src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { createSupabaseBrowserClient } from "@/lib/supabase/browser";
import { AppWorkspace } from "@/components/app-workspace";
import { AppHeaderDetailProvider } from "@/components/app-header-detail";
import { InstallOnboarding } from "@/components/pwa/install-onboarding";
import { NotificationOnboarding } from "@/components/pwa/notification-onboarding";
import { AppNotifications } from "@/components/social/app-notifications";
import { NudgeNotifications } from "@/components/social/nudge-notifications";
Expand Down Expand Up @@ -95,6 +96,9 @@ export function AppShell({
const [workspaceResetKeys, setWorkspaceResetKeys] = useState<
Record<string, number>
>({});
const [navUnread, setNavUnread] = useState({ friends: false, groups: false });
const [installOnboardingComplete, setInstallOnboardingComplete] =
useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const scrollPositionsRef = useRef<Record<string, number>>({});
const currentNav =
Expand All @@ -106,6 +110,7 @@ export function AppShell({
(isActive(displayPathname, "/app/groups") ||
isActive(displayPathname, "/app/units"));
const currentTitle = isNestedDetail ? headerDetail : currentNav.title;
const isFriendsView = isActive(displayPathname, "/app/friends");
const accountName =
authState.mode === "authenticated"
? authState.profile.display_name?.trim() || "Student"
Expand All @@ -125,6 +130,9 @@ export function AppShell({
},
[],
);
const handleInstallOnboardingComplete = useCallback(() => {
setInstallOnboardingComplete(true);
}, []);

useEffect(() => {
const frame = window.requestAnimationFrame(() => {
Expand Down Expand Up @@ -290,7 +298,10 @@ export function AppShell({
<>
<div className="mac-desktop-shell fixed inset-0 flex flex-col overflow-hidden bg-[var(--color-background)] lg:static lg:block lg:min-h-dvh lg:overflow-visible">
<div
className="mac-app-scroll mx-auto flex min-h-0 w-full max-w-6xl flex-1 overflow-y-auto lg:grid lg:min-h-dvh lg:max-w-none lg:grid-cols-[17.5rem_minmax(0,1fr)] lg:overflow-visible"
className={cn(
"mac-app-scroll mx-auto flex min-h-0 w-full max-w-6xl flex-1 overflow-y-auto lg:grid lg:min-h-dvh lg:max-w-none lg:grid-cols-[17.5rem_minmax(0,1fr)] lg:overflow-visible",
isFriendsView && "overflow-y-hidden",
)}
ref={scrollContainerRef}
>
<aside className="hidden lg:sticky lg:top-0 lg:flex lg:h-dvh lg:flex-col lg:border-r lg:border-[rgb(255_255_255/0.08)] lg:bg-[rgb(17_17_17/0.94)] lg:p-5 lg:backdrop-blur-xl">
Expand All @@ -303,6 +314,11 @@ export function AppShell({
{navItems.map((item) => (
<NavLink
href={item.href}
hasUnread={
item.href === "/app/friends"
? navUnread.friends
: item.href === "/app/groups" && navUnread.groups
}
icon={item.icon}
isActive={isActive(displayPathname, item.href)}
key={item.href}
Expand All @@ -321,7 +337,13 @@ export function AppShell({
/>
</aside>

<main className="min-w-0 flex-1 lg:min-h-dvh">
<main
className={cn(
"min-w-0 flex-1 lg:min-h-dvh",
isFriendsView &&
"flex min-h-0 flex-col overflow-hidden lg:overflow-visible",
)}
>
<header className="sticky top-0 z-20 bg-[rgb(23_23_23/0.94)] px-4 pb-3 pt-[calc(var(--safe-area-top)+0.85rem)] backdrop-blur lg:z-30 lg:border-b lg:border-[rgb(255_255_255/0.07)] lg:bg-[rgb(23_23_23/0.84)] lg:px-8 lg:py-5 xl:px-12">
<div className="relative mx-auto flex max-w-[80rem] items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3 lg:hidden">
Expand Down Expand Up @@ -371,12 +393,37 @@ export function AppShell({
</div>
</header>

<div className="px-4 pb-4 pt-3 sm:px-6 lg:mx-auto lg:w-full lg:max-w-[80rem] lg:px-8 lg:py-8 xl:px-12 xl:py-10">
<div className="lg:px-1 lg:py-2">
<div
className={cn(
"px-4 pb-4 pt-3 sm:px-6 lg:mx-auto lg:w-full lg:max-w-[80rem] lg:px-8 lg:py-8 xl:px-12 xl:py-10",
isFriendsView &&
"flex min-h-0 flex-1 flex-col overflow-hidden lg:block lg:overflow-visible",
)}
>
<div
className={cn(
"lg:px-1 lg:py-2",
isFriendsView && "flex min-h-0 flex-1 flex-col lg:block",
)}
>
<AppWorkspace
activePathname={displayPathname}
authState={authState}
fallback={children}
onDirectMessageUnreadChange={(hasUnread) =>
setNavUnread((current) =>
current.friends === hasUnread
? current
: { ...current, friends: hasUnread },
)
}
onGroupChatUnreadChange={(hasUnread) =>
setNavUnread((current) =>
current.groups === hasUnread
? current
: { ...current, groups: hasUnread },
)
}
resetKeys={workspaceResetKeys}
/>
</div>
Expand All @@ -389,6 +436,10 @@ export function AppShell({
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(displayPathname, item.href);
const hasUnread =
item.href === "/app/friends"
? navUnread.friends
: item.href === "/app/groups" && navUnread.groups;

return (
<Link
Expand All @@ -407,7 +458,10 @@ export function AppShell({
onPointerEnter={() => warmRoute(item.href)}
prefetch
>
<Icon aria-hidden size={22} strokeWidth={2.15} />
<span className="relative inline-flex">
<Icon aria-hidden size={22} strokeWidth={2.15} />
{hasUnread ? <NavUnreadDot /> : null}
</span>
<span className="max-w-full truncate px-0.5">
{"mobileLabel" in item ? item.mobileLabel : item.label}
</span>
Expand All @@ -422,7 +476,14 @@ export function AppShell({
<>
<AppNotifications userId={authState.user.id} />
<NudgeNotifications userId={authState.user.id} />
<NotificationOnboarding userId={authState.user.id} />
<InstallOnboarding
onComplete={handleInstallOnboardingComplete}
userId={authState.user.id}
/>
<NotificationOnboarding
enabled={installOnboardingComplete}
userId={authState.user.id}
/>
</>
) : null}
</>
Expand Down Expand Up @@ -466,13 +527,15 @@ function LogoMark({ size = "md" }: { size?: "sm" | "md" }) {

function NavLink({
href,
hasUnread,
icon: Icon,
isActive,
label,
onIntent,
onNavigate,
}: {
href: string;
hasUnread: boolean;
icon: React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>;
isActive: boolean;
label: string;
Expand All @@ -495,15 +558,18 @@ function NavLink({
onPointerEnter={() => onIntent(href)}
prefetch
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition",
isActive
? "bg-[rgb(20_20_20/0.1)]"
: "bg-[rgb(255_255_255/0.035)] group-hover:bg-[rgb(255_255_255/0.06)]",
)}
>
<Icon aria-hidden size={18} />
<span className="relative shrink-0">
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition",
isActive
? "bg-[rgb(20_20_20/0.1)]"
: "bg-[rgb(255_255_255/0.035)] group-hover:bg-[rgb(255_255_255/0.06)]",
)}
>
<Icon aria-hidden size={18} />
</span>
{hasUnread ? <NavUnreadDot /> : null}
</span>
<span className="min-w-0 flex-1">{label}</span>
<ChevronRight
Expand All @@ -520,6 +586,18 @@ function NavLink({
);
}

function NavUnreadDot() {
return (
<>
<span
aria-hidden
className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-danger)] ring-2 ring-[var(--color-background)]"
/>
<span className="sr-only">Unread messages</span>
</>
);
}

function DesktopAccount({
handle,
mode,
Expand Down
Loading