Skip to content
This repository was archived by the owner on Aug 15, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ NEXT_PUBLIC_DOCS_BASE_URL='http://localhost:3002'
NEXT_PUBLIC_LANDING_BASE_URL='http://localhost:3000'
NEXT_PUBLIC_LEARN_BASE_URL='http://localhost:3003'
NEXT_PUBLIC_SCOUT_BASE_URL='http://localhost:3004'
NEXT_PUBLIC_ATTENDANCE_BASE_URL='http://localhost:3005'

NODE_ENV='development'
46 changes: 46 additions & 0 deletions apps/attendance/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# OpenNext
/.open-next

# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
102 changes: 102 additions & 0 deletions apps/attendance/app/_actions/server-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use server";

import { auth } from "@spike/auth";
import { attendance, db } from "@spike/db";
import { eq, and, isNull } from "drizzle-orm";
import { headers } from "next/headers";

export async function logAction(action: string, userId: string) {
const today = new Date().toISOString().split("T")[0];

if (action === "check-in") {
// Always create a new record on check-in
await db.insert(attendance).values({
userId,
date: today,
status: "present",
checkInTime: new Date(),
} as any);
return;
}

if (action === "check-out") {
// Find the latest (open) record for today without a check-out time
const openRecord = await db
.select()
.from(attendance)
.where(
and(
eq(attendance.userId, userId),
eq(attendance.date, today),
isNull(attendance.checkOutTime),
),
)
.limit(1);

if (openRecord.length > 0) {
await db
.update(attendance)
.set({
checkOutTime: new Date(),
updatedAt: new Date(),
} as any)
.where(eq(attendance.id, openRecord[0].id));
}

return;
}
}

export async function getStatus(
userId: string,
): Promise<"checked-in" | "checked-out"> {
const today = new Date().toISOString().split("T")[0];

// Any open (no checkOutTime) record today means user is currently checked in
const openRecord = await db
.select()
.from(attendance)
.where(
and(
eq(attendance.userId, userId),
eq(attendance.date, today),
isNull(attendance.checkOutTime),
),
)
.limit(1);

if (openRecord.length > 0) {
return "checked-in";
}

return "checked-out";
}

export async function getCheckedInTime(userId: string): Promise<Date | null> {
const today = new Date().toISOString().split("T")[0];

// Get the current open session (no checkOutTime) for today
const openRecord = await db
.select()
.from(attendance)
.where(
and(
eq(attendance.userId, userId),
eq(attendance.date, today),
isNull(attendance.checkOutTime),
),
)
.limit(1);

if (openRecord.length === 0 || !openRecord[0].checkInTime) {
return null;
}

return openRecord[0].checkInTime;
}

export async function signOut() {
await auth.api.signOut({
headers: await headers(),
});
}
63 changes: 63 additions & 0 deletions apps/attendance/app/_components/attendance-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Button } from "@spike/ui/button";
import { Card, CardContent } from "@spike/ui/card";
import { Clock } from "lucide-react";
import { TimeDisplay } from "./time-display";

interface AttendanceStatusProps {
isCheckedIn: boolean;
checkInTime: string | null;
checkOutTime: string | null;
onCheckInOut: (status: string) => void;
loading?: boolean;
}

export function AttendanceStatus({
isCheckedIn,
checkInTime,
checkOutTime,
onCheckInOut,
loading = false,
}: AttendanceStatusProps) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-green-400 to-blue-500 rounded-full flex items-center justify-center">
<Clock className="w-10 h-10 text-white" />
</div>

<div>
<h2 className="text-2xl font-bold text-gray-800">
{isCheckedIn ? "Checked In" : "Not Checked In"}
</h2>
<p className="text-gray-600">
{isCheckedIn
? "You are currently in session"
: "Ready to start your session?"}
</p>
</div>

<TimeDisplay checkInTime={checkInTime} checkOutTime={checkOutTime} />

<Button
onClick={() => onCheckInOut(isCheckedIn ? "check-out" : "check-in")}
disabled={loading}
className={`w-full text-lg py-6 ${
isCheckedIn
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-green-500 hover:bg-green-600 text-white"
}`}
>
{loading
? isCheckedIn
? "Checking Out..."
: "Checking In..."
: isCheckedIn
? "Check Out"
: "Check In"}
</Button>
</div>
</CardContent>
</Card>
);
}
78 changes: 78 additions & 0 deletions apps/attendance/app/_components/attendance-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { useState } from "react";
import { AttendanceStatus } from "./attendance-status";
import { StatusIndicator } from "./status-indicator";
import { getCurrentDate } from "~/lib/date-helper";
import { UserHeader } from "./user-header";
import { asUrl } from "@spike/config/paths.config";
import sharedEnv from "@spike/env/env.shared";
import { redirect } from "next/navigation";
import { logAction } from "~/_actions/server-actions";
import { ViewAdminDashboard } from "./view-admin-dashboard";

export default function AttendanceTracker({
session,
isCheckedIn: initialCheckedIn,
checkInTime: initialCheckInTime,
}: {
session?: any;
isCheckedIn: boolean;
checkInTime?: Date;
}) {
const [isCheckedIn, setIsCheckedIn] = useState(initialCheckedIn);
const [checkInTime, setCheckInTime] = useState<Date | undefined>(
initialCheckInTime,
);
const [checkOutTime, setCheckOutTime] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

if (!session) {
redirect(
asUrl("auth", "login") + "?redirectUrl=" + sharedEnv.ATTENDANCE_BASE_URL,
);
}

const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};

const checkInOutHandler = async (status: string) => {
if (!session) return;
setLoading(true);
await logAction(status, session.user.id);
if (status === "check-in") {
setIsCheckedIn(true);
setCheckInTime(new Date());
setCheckOutTime(null);
} else {
setIsCheckedIn(false);
setCheckOutTime(formatTime(new Date()));
setCheckInTime(undefined);
}
setLoading(false);
};

return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div className="max-w-md mx-auto space-y-6">
<UserHeader
userName={session.user.name}
currentDate={getCurrentDate()}
/>

<AttendanceStatus
isCheckedIn={isCheckedIn}
checkInTime={checkInTime ? formatTime(checkInTime) : null}
checkOutTime={checkOutTime}
onCheckInOut={checkInOutHandler}
loading={loading}
/>

<StatusIndicator isCheckedIn={isCheckedIn} />

<ViewAdminDashboard hasAccess={session.user.role === "admin"} />
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions apps/attendance/app/_components/install-app-ios.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { useEffect, useState } from "react";
import { Card, CardContent } from "@spike/ui/card";

export default function IOSInstallPrompt() {
const [visible, setVisible] = useState(false);

useEffect(() => {
const isIos = /iphone|ipad|ipod/.test(
window.navigator.userAgent.toLowerCase(),
);
const isStandalone =
"standalone" in window.navigator && (window.navigator as any).standalone;
if (isIos && !isStandalone) {
setVisible(true);
}
}, []);

if (!visible) return null;

return (
<div className="fixed bottom-4 right-4 z-50 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="bg-yellow-100 text-black w-80">
<CardContent className="p-4">
<p className="text-sm font-medium">
To install this app: tap <strong>Share</strong> →{" "}
<strong>Add to Home Screen</strong>
</p>
</CardContent>
</Card>
</div>
);
}
22 changes: 22 additions & 0 deletions apps/attendance/app/_components/status-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Card, CardContent } from "@spike/ui/card";

interface StatusIndicatorProps {
isCheckedIn: boolean;
}

export function StatusIndicator({ isCheckedIn }: StatusIndicatorProps) {
return (
<Card>
<CardContent className="flex flex-col space-y-4">
<div className="flex items-center justify-center space-x-2">
<div
className={`w-3 h-3 rounded-full ${isCheckedIn ? "bg-green-500" : "bg-gray-300"}`}
></div>
<span className="text-sm text-gray-600">
Status: {isCheckedIn ? "Active" : "Inactive"}
</span>
</div>
</CardContent>
</Card>
);
}
23 changes: 23 additions & 0 deletions apps/attendance/app/_components/time-display.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
interface TimeDisplayProps {
checkInTime: string | null
checkOutTime: string | null
}

export function TimeDisplay({ checkInTime, checkOutTime }: TimeDisplayProps) {
if (!checkInTime) return null

return (
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-600">Check In:</span>
<span className="text-sm font-bold text-green-600">{checkInTime}</span>
</div>
{checkOutTime && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-600">Check Out:</span>
<span className="text-sm font-bold text-red-600">{checkOutTime}</span>
</div>
)}
</div>
)
}
Loading
Loading