onChange("friend_requests"),
)
+ .on(
+ "postgres_changes",
+ { event: "*", schema: "public", table: "direct_messages" },
+ () => onChange("direct_messages"),
+ )
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "super_nudge_requests" },
@@ -1804,6 +1816,16 @@ export function subscribeToRemoteAppChanges(
{ event: "*", schema: "public", table: "app_notifications" },
() => onChange("app_notifications"),
)
+ .on(
+ "postgres_changes",
+ { event: "*", schema: "public", table: "group_chat_messages" },
+ () => onChange("group_chat_messages"),
+ )
+ .on(
+ "postgres_changes",
+ { event: "*", schema: "public", table: "group_chat_read_receipts" },
+ () => onChange("group_chat_read_receipts"),
+ )
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "groups" },
@@ -1846,6 +1868,24 @@ export function subscribeToRemoteAppChanges(
};
}
+export async function fetchRemoteDirectMessageUnreadCount({
+ supabase,
+ userId,
+}: {
+ supabase: SupabaseClient;
+ userId: string;
+}) {
+ const { count, error } = await supabase
+ .from("direct_messages")
+ .select("id", { count: "exact", head: true })
+ .eq("recipient_id", userId)
+ .is("read_at", null);
+
+ if (error) throw error;
+
+ return count ?? 0;
+}
+
async function fetchRemoteSubjects(supabase: SupabaseClient, userId: string) {
const { data: existing, error: fetchError } = await supabase
.from("subjects")
@@ -2006,7 +2046,8 @@ function friendFromProfile(
);
const activeSession =
userSessions.find((session) => session.status === "active") ?? null;
- const totals = getSessionTotals(userSessions, now);
+ const dailyStudySeconds = getDailySessionTotals(userSessions, now);
+ const totals = getSessionTotals(userSessions, dailyStudySeconds, now);
return {
id: profile.id,
@@ -2023,50 +2064,51 @@ function friendFromProfile(
weekSeconds: totals.week,
monthSeconds: totals.month,
allTimeSeconds: totals.allTime,
- dailyStudySeconds: getDailySessionTotals(userSessions, now),
+ dailyStudySeconds,
activeStartedAt: activeSession?.started_at ?? null,
activeUpdatedAt: activeSession ? now.toISOString() : null,
subjectSeconds: {},
};
}
-function getSessionTotals(sessions: SessionRow[], now = new Date()) {
- const todayKey = now.toISOString().slice(0, 10);
- const weekStart = new Date(now);
- weekStart.setDate(now.getDate() - 6);
- weekStart.setHours(0, 0, 0, 0);
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
-
- return sessions.reduce(
- (totals, session) => {
- if (session.status === "voided") {
- return totals;
- }
-
- const startedAt = new Date(session.started_at);
- const seconds = session.ended_at
- ? (session.duration_seconds ??
- getElapsedSeconds(session.started_at, new Date(session.ended_at)))
- : getElapsedSeconds(session.started_at, now);
-
- totals.allTime += seconds;
-
- if (startedAt.toISOString().slice(0, 10) === todayKey) {
- totals.day += seconds;
- }
-
- if (startedAt >= weekStart) {
- totals.week += seconds;
+function getSessionTotals(
+ sessions: SessionRow[],
+ dailyStudySeconds: Record
,
+ now = new Date(),
+) {
+ const todayKey = getLocalDateKey(now);
+ const calendarDay = new Date(`${todayKey}T00:00:00Z`).getUTCDay();
+ const weekStartKey = addDateKeyDays(todayKey, -((calendarDay + 6) % 7));
+ const monthStartKey = `${todayKey.slice(0, 7)}-01`;
+ const totals = Object.entries(dailyStudySeconds).reduce(
+ (result, [dateKey, seconds]) => {
+ if (dateKey >= weekStartKey && dateKey <= todayKey) {
+ result.week += seconds;
}
- if (startedAt >= monthStart) {
- totals.month += seconds;
+ if (dateKey >= monthStartKey && dateKey <= todayKey) {
+ result.month += seconds;
}
- return totals;
+ return result;
+ },
+ {
+ day: dailyStudySeconds[todayKey] ?? 0,
+ month: 0,
+ week: 0,
},
- { allTime: 0, day: 0, month: 0, week: 0 },
);
+
+ return {
+ ...totals,
+ allTime: sessions.reduce(
+ (total, session) =>
+ session.status === "voided"
+ ? total
+ : total + getSessionDurationSeconds(session, now),
+ 0,
+ ),
+ };
}
function getDailySessionTotals(sessions: SessionRow[], now = new Date()) {
@@ -2075,17 +2117,36 @@ function getDailySessionTotals(sessions: SessionRow[], now = new Date()) {
return totals;
}
- const seconds = session.ended_at
- ? (session.duration_seconds ??
- getElapsedSeconds(session.started_at, new Date(session.ended_at)))
- : getElapsedSeconds(session.started_at, now);
- const key = getLocalDateKey(new Date(session.started_at));
+ const sessionEnd = session.ended_at
+ ? new Date(session.ended_at)
+ : new Date(now);
+ let cursor = new Date(session.started_at);
+
+ while (cursor < sessionEnd) {
+ const key = getLocalDateKey(cursor);
+ const nextDay = getAustralianDateStart(addDateKeyDays(key, 1));
+ const segmentEnd = nextDay < sessionEnd ? nextDay : new Date(sessionEnd);
+ const seconds = Math.max(
+ 0,
+ Math.floor((segmentEnd.getTime() - cursor.getTime()) / 1000),
+ );
+
+ totals[key] = (totals[key] ?? 0) + seconds;
+ if (segmentEnd <= cursor) break;
+ cursor = segmentEnd;
+ }
- totals[key] = (totals[key] ?? 0) + seconds;
return totals;
}, {});
}
+function getSessionDurationSeconds(session: SessionRow, now: Date) {
+ return session.ended_at
+ ? (session.duration_seconds ??
+ getElapsedSeconds(session.started_at, new Date(session.ended_at)))
+ : getElapsedSeconds(session.started_at, now);
+}
+
function normalizeGroupIcon(icon: string | null | undefined): GroupIconKey {
return GROUP_ICON_KEYS.includes(icon as GroupIconKey)
? (icon as GroupIconKey)
diff --git a/src/lib/supabase/group-chat-read-receipts.ts b/src/lib/supabase/group-chat-read-receipts.ts
index cd0dcc2..7839c09 100644
--- a/src/lib/supabase/group-chat-read-receipts.ts
+++ b/src/lib/supabase/group-chat-read-receipts.ts
@@ -12,6 +12,24 @@ type GroupChatReadReceiptRow = {
user_id: string;
};
+type GroupChatUnreadCountRow = {
+ group_id: string;
+ unread_count: number | string;
+};
+
+export async function fetchGroupChatUnreadCounts(supabase: SupabaseClient) {
+ const { data, error } = await supabase.rpc("list_group_chat_unread_counts");
+
+ if (error) throw error;
+
+ return Object.fromEntries(
+ ((data ?? []) as GroupChatUnreadCountRow[]).map((row) => [
+ row.group_id,
+ Number(row.unread_count) || 0,
+ ]),
+ ) as Record;
+}
+
export async function fetchGroupChatReadReceipts(
supabase: SupabaseClient,
groupId: string,
diff --git a/src/lib/supabase/profile.ts b/src/lib/supabase/profile.ts
index f383494..6f06791 100644
--- a/src/lib/supabase/profile.ts
+++ b/src/lib/supabase/profile.ts
@@ -10,6 +10,7 @@ export type Profile = {
course: string | null;
study_icon: string;
profile_color: string;
+ is_discoverable: boolean;
access_status: AccessStatus;
access_granted_at: string | null;
created_at: string;
@@ -27,7 +28,7 @@ export async function getProfileById(
const { data, error } = await supabase
.from("profiles")
.select(
- "id, display_name, username, avatar_url, course, study_icon, profile_color, access_status, access_granted_at, created_at, updated_at",
+ "id, display_name, username, avatar_url, course, study_icon, profile_color, is_discoverable, access_status, access_granted_at, created_at, updated_at",
)
.eq("id", userId)
.maybeSingle();
diff --git a/src/lib/timer.ts b/src/lib/timer.ts
index 8cf78c8..8276aa8 100644
--- a/src/lib/timer.ts
+++ b/src/lib/timer.ts
@@ -4,6 +4,19 @@ type CompletedSession = {
endedAt: string;
};
+export const STUDY_TIME_ZONE = "Australia/Sydney";
+
+const australianDateTimeFormatter = new Intl.DateTimeFormat("en-AU", {
+ day: "2-digit",
+ hour: "2-digit",
+ hourCycle: "h23",
+ minute: "2-digit",
+ month: "2-digit",
+ second: "2-digit",
+ timeZone: STUDY_TIME_ZONE,
+ year: "numeric",
+});
+
export function getElapsedSeconds(startedAt: string, now = new Date()) {
const started = new Date(startedAt).getTime();
return Math.max(0, Math.floor((now.getTime() - started) / 1000));
@@ -20,11 +33,68 @@ export function formatDuration(totalSeconds: number) {
}
export function getLocalDateKey(date: Date) {
- const year = date.getFullYear();
- const month = `${date.getMonth() + 1}`.padStart(2, "0");
- const day = `${date.getDate()}`.padStart(2, "0");
+ const parts = getAustralianDateTimeParts(date);
+
+ return `${parts.year}-${`${parts.month}`.padStart(2, "0")}-${`${parts.day}`.padStart(2, "0")}`;
+}
+
+export function addDateKeyDays(dateKey: string, days: number) {
+ const { day, month, year } = parseDateKey(dateKey);
+ const next = new Date(Date.UTC(year, month - 1, day + days));
+
+ return [
+ next.getUTCFullYear(),
+ `${next.getUTCMonth() + 1}`.padStart(2, "0"),
+ `${next.getUTCDate()}`.padStart(2, "0"),
+ ].join("-");
+}
+
+export function getAustralianDayRange(date = new Date()) {
+ const dateKey = getLocalDateKey(date);
+
+ return {
+ end: getAustralianDateStart(addDateKeyDays(dateKey, 1)),
+ start: getAustralianDateStart(dateKey),
+ };
+}
+
+export function getAustralianDateStart(dateKey: string) {
+ const { day, month, year } = parseDateKey(dateKey);
+ const desiredWallClock = Date.UTC(year, month - 1, day);
+ let candidate = desiredWallClock;
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const parts = getAustralianDateTimeParts(new Date(candidate));
+ const representedWallClock = Date.UTC(
+ parts.year,
+ parts.month - 1,
+ parts.day,
+ parts.hour,
+ parts.minute,
+ parts.second,
+ );
+ const adjustment = desiredWallClock - representedWallClock;
- return `${year}-${month}-${day}`;
+ candidate += adjustment;
+ if (!adjustment) break;
+ }
+
+ return new Date(candidate);
+}
+
+export function getIntervalOverlapSeconds(
+ intervalStart: Date | string,
+ intervalEnd: Date | string,
+ rangeStart: Date,
+ rangeEnd: Date,
+) {
+ const start = Math.max(
+ new Date(intervalStart).getTime(),
+ rangeStart.getTime(),
+ );
+ const end = Math.min(new Date(intervalEnd).getTime(), rangeEnd.getTime());
+
+ return Math.max(0, Math.floor((end - start) / 1000));
}
export function getSessionSeconds(session: CompletedSession) {
@@ -57,3 +127,27 @@ export function groupSessionsBySubject(sessions: CompletedSession[]) {
export function isLongSession(startedAt: string, now = new Date()) {
return getElapsedSeconds(startedAt, now) >= 6 * 60 * 60;
}
+
+function getAustralianDateTimeParts(date: Date) {
+ const values = Object.fromEntries(
+ australianDateTimeFormatter
+ .formatToParts(date)
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, Number(part.value)]),
+ );
+
+ return {
+ day: values.day,
+ hour: values.hour,
+ minute: values.minute,
+ month: values.month,
+ second: values.second,
+ year: values.year,
+ };
+}
+
+function parseDateKey(dateKey: string) {
+ const [year, month, day] = dateKey.split("-").map(Number);
+
+ return { day, month, year };
+}
diff --git a/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql
new file mode 100644
index 0000000..9a8839c
--- /dev/null
+++ b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql
@@ -0,0 +1,16 @@
+-- Allow server-only notification delivery code to read recipients and
+-- subscriptions, then create and mark notification records as delivered.
+
+grant select on table
+ public.groups,
+ public.group_members,
+ public.profiles,
+ public.user_group_notification_settings,
+ public.user_notification_preferences,
+ public.push_subscriptions
+to service_role;
+
+grant select, insert, update on table public.app_notifications
+to service_role;
+
+notify pgrst, 'reload schema';
diff --git a/supabase/migrations/20260730060000_group_chat_unread_counts.sql b/supabase/migrations/20260730060000_group_chat_unread_counts.sql
new file mode 100644
index 0000000..2e19c1f
--- /dev/null
+++ b/supabase/migrations/20260730060000_group_chat_unread_counts.sql
@@ -0,0 +1,37 @@
+-- Efficient unread group-message counts for the signed-in member.
+
+create or replace function public.list_group_chat_unread_counts()
+returns table (
+ group_id uuid,
+ unread_count bigint
+)
+language sql
+stable
+security definer
+set search_path = public
+as $$
+ select
+ membership.group_id,
+ count(message.id)::bigint as unread_count
+ from public.group_members as membership
+ left join public.group_chat_read_receipts as receipt
+ on receipt.group_id = membership.group_id
+ and receipt.user_id = membership.user_id
+ left join public.group_chat_messages as message
+ on message.group_id = membership.group_id
+ and message.user_id <> membership.user_id
+ and message.deleted_at is null
+ and message.created_at > coalesce(
+ receipt.last_read_at,
+ membership.joined_at
+ )
+ where membership.user_id = auth.uid()
+ and membership.status = 'active'
+ group by membership.group_id;
+$$;
+
+revoke all on function public.list_group_chat_unread_counts() from public;
+grant execute on function public.list_group_chat_unread_counts()
+to authenticated;
+
+notify pgrst, 'reload schema';
diff --git a/supabase/migrations/20260731010000_profile_discoverability.sql b/supabase/migrations/20260731010000_profile_discoverability.sql
new file mode 100644
index 0000000..f2ac5b6
--- /dev/null
+++ b/supabase/migrations/20260731010000_profile_discoverability.sql
@@ -0,0 +1,104 @@
+alter table public.profiles
+add column if not exists is_discoverable boolean not null default true;
+
+grant select (is_discoverable) on table public.profiles to authenticated;
+
+create or replace function public.set_profile_discoverability(
+ next_is_discoverable boolean
+)
+returns boolean
+language plpgsql
+security definer
+set search_path = public
+as $$
+begin
+ if auth.uid() is null then
+ raise exception 'AUTH_REQUIRED';
+ end if;
+
+ update public.profiles
+ set is_discoverable = coalesce(next_is_discoverable, true)
+ where id = auth.uid();
+
+ if not found then
+ raise exception 'PROFILE_NOT_FOUND';
+ end if;
+
+ return coalesce(next_is_discoverable, true);
+end;
+$$;
+
+revoke all on function public.set_profile_discoverability(boolean) from public;
+grant execute on function public.set_profile_discoverability(boolean)
+to authenticated;
+
+create or replace function public.list_friend_candidates()
+returns table (
+ user_id uuid,
+ display_name text,
+ username text,
+ avatar_url text,
+ study_icon text,
+ profile_color text,
+ mutual_friend_count bigint,
+ request_direction text
+)
+language sql
+security definer
+set search_path = public
+stable
+as $$
+ select
+ candidate.id as user_id,
+ candidate.display_name,
+ candidate.username,
+ candidate.avatar_url,
+ candidate.study_icon,
+ candidate.profile_color,
+ (
+ select count(*)
+ from public.friendships mine
+ join public.friendships theirs
+ on theirs.friend_id = mine.friend_id
+ where mine.user_id = auth.uid()
+ and theirs.user_id = candidate.id
+ ) as mutual_friend_count,
+ pending.direction as request_direction
+ from public.profiles candidate
+ left join lateral (
+ select
+ case
+ when request.sender_id = auth.uid() then 'outgoing'
+ else 'incoming'
+ end as direction
+ from public.friend_requests request
+ where request.status = 'pending'
+ and (
+ (
+ request.sender_id = auth.uid()
+ and request.recipient_id = candidate.id
+ )
+ or
+ (
+ request.sender_id = candidate.id
+ and request.recipient_id = auth.uid()
+ )
+ )
+ order by request.created_at desc
+ limit 1
+ ) pending on true
+ where candidate.id <> auth.uid()
+ and coalesce(candidate.is_discoverable, true)
+ and not exists (
+ select 1
+ from public.friendships friendship
+ where friendship.user_id = auth.uid()
+ and friendship.friend_id = candidate.id
+ )
+ order by mutual_friend_count desc, candidate.display_name, candidate.username;
+$$;
+
+revoke all on function public.list_friend_candidates() from public;
+grant execute on function public.list_friend_candidates() to authenticated;
+
+notify pgrst, 'reload schema';