Skip to content
Draft
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
74 changes: 68 additions & 6 deletions apps/extension/entrypoints/ui/scripts/attendance-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MeetingState, Participant } from "@/types";
import { BASE_URL } from "@/utils/constants";
import { isSameDay } from "date-fns";
import { isSameDay, format } from "date-fns";

const SAVE_INTERVAL = 60000;
let lastSaveTime = new Date().getTime();
Expand Down Expand Up @@ -126,16 +126,78 @@ const saveAttendanceData = async (isFinal = false) => {
participant.leaveTime = participant.lastAttendedTimeStamp;
});

const dataString = JSON.stringify(window.trackit.meetData, replacer);

await browser.storage.local.set({
[window.trackit.meetData.uuid]: JSON.stringify(
window.trackit.meetData,
replacer
),
[window.trackit.meetData.uuid]: dataString,
});

if (isFinal) {
window.open(`${BASE_URL}/save-report`);
const uploaded = await uploadAttendanceData(window.trackit.meetData);
if (uploaded && !uploaded.redirected) {
// If uploaded successfully, we can remove it from local storage
await browser.storage.local.remove(window.trackit.meetData.uuid);
// Redirect to the report page
window.open(`${BASE_URL}/g/${uploaded.groupId}/r/${uploaded.slug}`);
} else if (uploaded?.redirected) {
// Do nothing, user was redirected to login
} else {
// Fallback to the old method
alert("Direct upload failed. Falling back to local storage method.");
window.open(`${BASE_URL}/save-report`);
}
}
};

const uploadAttendanceData = async (meetData: MeetingState) => {
try {
const { authToken } = await browser.storage.local.get("authToken");
if (!authToken) {
console.log("No auth token found, redirecting to login.");
const confirmLogin = window.confirm("You are not signed in. Would you like to sign in to save your attendance report directly to the cloud?");
if (confirmLogin) {
window.open(`${BASE_URL}/api/auth/signin`);
return { redirected: true }; // Special return value to indicate redirection
}
return null;
}

const participants = Array.from(meetData.participants.values()).map((p) => ({
name: p.name,
joinTime: p.joinTime.toISOString(),
leaveTime: p.leaveTime?.toISOString() || p.lastAttendedTimeStamp.toISOString(),
avatarUrl: p.avatarUrl,
attendedDuration: p.attendedDuration,
}));

const payload = {
meetCode: meetData.meetCode,
date: format(meetData.date, "dd/MM/yyyy"),
startTime: format(meetData.startTime, "HH:mm:ss"),
stopTime: format(meetData.endTime, "HH:mm:ss"),
participants,
groupId: meetData.groupId,
};

const response = await fetch(`${BASE_URL}/api/reports/upload`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(payload),
});

if (response.ok) {
const result = await response.json();
return result;
} else if (response.status === 401) {
console.log("Unauthorized, token might be expired.");
}
} catch (error) {
console.error("Error uploading attendance data:", error);
}
return null;
};

export const tracker = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default async function GroupLayout({
href: `/g/${groupId}/schedule`,
},
{
title: "Members",
title: "People",
href: `/g/${groupId}/members`,
},
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";
import { inviteUserToGroup } from "@/lib/api/groups";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@repo/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/ui/form";
import { LoadingCircle } from "@repo/ui/icons";
import { Input } from "@repo/ui/input";
import { toast } from "@repo/ui/sonner";
import { UserPlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

const FormSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address." }),
});

export default function InviteMemberModal({ groupId }: { groupId: string }) {
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);

const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: "",
},
});

async function onSubmit(data: z.infer<typeof FormSchema>) {
setIsLoading(true);
const res = await inviteUserToGroup({
groupId,
email: data.email,
});
if (res.success) {
toast.success(res.message);
setIsOpen(false);
form.reset();
} else {
toast.error(res.message);
}
setIsLoading(false);
}

return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlusIcon className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Member</DialogTitle>
<DialogDescription>
Send an invitation email with the join code to add someone to this
group.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 my-4"
>
<FormField
control={form.control}
name={"email"}
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
placeholder="email@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<LoadingCircle />
<span className="ml-2">Sending Invitation</span>
</>
) : (
"Send Invitation"
)}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { dbClient } from "@/lib/db/db_client";
import { getServerSession } from "next-auth";
import GroupMemberActions from "./member-actions";
import InviteMemberModal from "./invite-modal";

export default async function GroupInterface({
params: { groupId },
Expand Down Expand Up @@ -38,11 +39,16 @@ export default async function GroupInterface({
return (
<Card className="bg-secondary border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Members</CardTitle>
<Input
placeholder="Search people"
className="max-w-sm bg-gray-700 border-gray-600 text-gray-100"
/>
<CardTitle>People</CardTitle>
<div className="flex items-center gap-2">
<Input
placeholder="Search people"
className="max-w-sm bg-gray-700 border-gray-600 text-gray-100"
/>
{(current_user.role === "ADMIN" || current_user.role === "OWNER") && (
<InviteMemberModal groupId={groupId} />
)}
</div>
</CardHeader>
<CardContent>
<Table className="mt-4 border rounded-lg">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,84 @@
const GroupStreamPage = () => {
import { dbClient } from "@/lib/db/db_client";
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
import { TypographyH3, TypographyP } from "@repo/ui/typography";
import { FileBarChart2Icon, UsersIcon, CalendarIcon } from "lucide-react";

const GroupStreamPage = async ({
params: { groupId },
}: {
params: { groupId: string };
}) => {
const stats = await dbClient.transaction().execute(async (trx) => {
const membersCount = await trx
.selectFrom("GroupMember")
.select(({ fn }) => fn.count<number>("id").as("count"))
.where("groupId", "=", groupId)
.executeTakeFirst();

const reportsCount = await trx
.selectFrom("AttendanceReport")
.innerJoin("Meeting", "AttendanceReport.meetingId", "Meeting.id")
.select(({ fn }) => fn.count<number>("AttendanceReport.id").as("count"))
.where("Meeting.groupId", "=", groupId)
.executeTakeFirst();

const upcomingMeetingsCount = await trx
.selectFrom("Meeting")
.select(({ fn }) => fn.count<number>("id").as("count"))
.where("groupId", "=", groupId)
.where("date", ">=", new Date())
.executeTakeFirst();

return {
members: membersCount?.count || 0,
reports: reportsCount?.count || 0,
meetings: upcomingMeetingsCount?.count || 0,
};
});

return (
<div>
<h1>GroupStreamPage</h1>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-primary/10 border-primary/20">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-primary">Total Members</CardTitle>
<UsersIcon className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.members}</div>
</CardContent>
</Card>
<Card className="bg-green-500/10 border-green-500/20">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-green-500">Attendance Reports</CardTitle>
<FileBarChart2Icon className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.reports}</div>
</CardContent>
</Card>
<Card className="bg-purple-500/10 border-purple-500/20">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-purple-500">Upcoming Meetings</CardTitle>
<CalendarIcon className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.meetings}</div>
</CardContent>
</Card>
</div>

<Card className="bg-secondary/40">
<CardHeader>
<TypographyH3>Welcome to your Group Stream</TypographyH3>
</CardHeader>
<CardContent>
<TypographyP>
This is your central hub for tracking attendance, scheduling meetings, and managing group members.
Use the tabs above to navigate through different features.
</TypographyP>
</CardContent>
</Card>
</div>
);
};
Expand Down
Loading