Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
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`;
}
111 changes: 96 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,9 @@ export function AppShell({
(isActive(displayPathname, "/app/groups") ||
isActive(displayPathname, "/app/units"));
const currentTitle = isNestedDetail ? headerDetail : currentNav.title;
const isFriendsView = isActive(displayPathname, "/app/friends");
const isShortMobileView =
displayPathname === "/app/statistics" || displayPathname === "/app/profile";
const accountName =
authState.mode === "authenticated"
? authState.profile.display_name?.trim() || "Student"
Expand All @@ -125,6 +132,9 @@ export function AppShell({
},
[],
);
const handleInstallOnboardingComplete = useCallback(() => {
setInstallOnboardingComplete(true);
}, []);

useEffect(() => {
const frame = window.requestAnimationFrame(() => {
Expand Down Expand Up @@ -290,7 +300,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 || isShortMobileView) && "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 +316,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 +339,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 || isShortMobileView) &&
"flex min-h-0 flex-col overflow-hidden",
)}
>
<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 +395,38 @@ 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 || isShortMobileView) &&
"flex min-h-0 flex-1 flex-col overflow-hidden",
)}
>
<div
className={cn(
"lg:px-1 lg:py-2",
(isFriendsView || isShortMobileView) &&
"flex min-h-0 flex-1 flex-col",
)}
>
<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 +439,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 +461,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 +479,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 +530,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 +561,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 +589,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