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
119 changes: 117 additions & 2 deletions __tests__/AttendanceAction.break-rules.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ jest.mock("@expo/vector-icons", () => {
};
});

let focusListener = null;

const mockNavigation = {
setOptions: jest.fn(),
goBack: jest.fn(),
navigate: jest.fn(),
addListener: jest.fn((event, cb) => {
if (event === "focus" && typeof cb === "function") {
// Keep tests deterministic: do not auto-trigger focus callback.
focusListener = cb;
}

return jest.fn();
Expand Down Expand Up @@ -109,17 +111,23 @@ const createStore = (attendanceOverrides = {}) =>
const renderScreen = (attendanceOverrides = {}) => {
const store = createStore(attendanceOverrides);

return render(
const utils = render(
<Provider store={store}>
<AttendanceAction />
</Provider>,
);

return {
...utils,
store,
};
};

describe("AttendanceAction break rules", () => {
beforeEach(async () => {
jest.clearAllMocks();
jest.useRealTimers();
focusListener = null;
await AsyncStorage.clear();

await AsyncStorage.setItem("restrict_location", "0");
Expand All @@ -145,6 +153,113 @@ describe("AttendanceAction break rules", () => {
});
});

it("keeps checkin start time stable after focus refresh", async () => {
const stableCheckinTime = new Date("2026-04-25T08:30:00.000Z").getTime();

await AsyncStorage.setItem("checkinStartTime", String(stableCheckinTime));

attendanceService.getAttendanceStatus.mockResolvedValue({
custom_in: 1,
checkin_time: String(stableCheckinTime),
});

const screen = renderScreen({
checkin: true,
checkinTime: stableCheckinTime,
});

await waitFor(() => {
expect(focusListener).toEqual(expect.any(Function));
});

await act(async () => {
await focusListener();
});

await waitFor(() => {
expect(screen.store.getState().attendance.checkinTime).toBe(
stableCheckinTime,
);
});
});

it("keeps newer local checkin start when backend timestamp is stale", async () => {
const staleBackendCheckinTime = new Date(
"2026-04-25T08:30:00.000Z",
).getTime();
const freshLocalCheckinTime = new Date(
"2026-04-25T10:00:00.000Z",
).getTime();

await AsyncStorage.setItem(
"checkinStartTime",
String(freshLocalCheckinTime),
);

attendanceService.getAttendanceStatus.mockResolvedValue({
custom_in: 1,
checkin_time: String(staleBackendCheckinTime),
});

const screen = renderScreen({
checkin: true,
checkinTime: freshLocalCheckinTime,
});

await waitFor(() => {
expect(focusListener).toEqual(expect.any(Function));
});

await act(async () => {
await focusListener();
});

await waitFor(() => {
expect(screen.store.getState().attendance.checkinTime).toBe(
freshLocalCheckinTime,
);
});
});

it("ignores status checkin time older than last local checkout", async () => {
jest.useFakeTimers();

const now = new Date("2026-04-25T10:00:00.000Z");
jest.setSystemTime(now);

const staleBackendCheckinTime = new Date(
"2026-04-25T08:30:00.000Z",
).getTime();
const lastCheckoutTime = new Date("2026-04-25T09:45:00.000Z").getTime();

await AsyncStorage.removeItem("checkinStartTime");
await AsyncStorage.setItem("lastCheckoutTime", String(lastCheckoutTime));

attendanceService.getAttendanceStatus.mockResolvedValue({
custom_in: 1,
checkin_time: String(staleBackendCheckinTime),
});

const screen = renderScreen({
checkin: false,
checkinTime: null,
});

await waitFor(() => {
expect(focusListener).toEqual(expect.any(Function));
});

await act(async () => {
await focusListener();
});

await waitFor(() => {
expect(screen.store.getState().attendance.checkinTime).toBe(
now.getTime(),
);
});
});

it("disables break after a completed break for the day", async () => {
attendanceService.getTodayBreaks.mockResolvedValue({
total_break_minutes: 25,
Expand Down
97 changes: 97 additions & 0 deletions components/AttendanceAction/AttendanceActionForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { COLORS } from "../../constants";

function AttendanceActionForm({
dateTime,
locationStatusText,
distanceInfo,
restrictLocation,
checkin,
actionLoading,
isLocationBlocked,
onCheckInOutPress,
onBreakPress,
breakDisabled,
breakButtonLabel,
breakButtonToneClass,
monthlyCapMessage,
}) {
return (
<View className="h-72 mt-4 mb-24">
<View className="p-3">
<Text className="text-base text-gray-500 font-semibold">
DATE AND TIME *
</Text>
<View className="flex-row items-end border-b border-gray-400 pb-2 mb-6 justify-between">
<Text className="text-sm font-medium text-gray-500">{dateTime}</Text>
<MaterialCommunityIcons
name="calendar-month"
size={28}
color={COLORS.gray}
/>
</View>

<Text className="text-base text-gray-500 font-semibold">
LOCATION *
</Text>
<View className="flex-row items-end border-b border-gray-400 pb-2 mb-4 justify-between">
<Text className="text-sm font-medium text-gray-500">
{locationStatusText}
</Text>
<MaterialCommunityIcons
name="map-marker-radius-outline"
size={28}
color={COLORS.gray}
/>
</View>

{restrictLocation === "1" && distanceInfo && (
<View className="mb-3">
<Text className="text-xs text-gray-400">
Distance: {distanceInfo.distance} m | Allowed:{" "}
{distanceInfo.radius} m
</Text>
</View>
)}

<TouchableOpacity
className={`justify-center items-center h-16 w-full mt-4 rounded-2xl ${
checkin ? "bg-red-600" : "bg-green-600"
} ${isLocationBlocked ? "opacity-50" : ""}`}
disabled={actionLoading || isLocationBlocked}
onPress={onCheckInOutPress}
>
<Text className="text-xl font-bold text-white">
{checkin ? "CHECK-OUT" : "CHECK-IN"}
</Text>
</TouchableOpacity>

{checkin && (
<View>
<TouchableOpacity
className={`justify-center items-center h-16 w-full mt-4 rounded-2xl ${breakButtonToneClass}`}
disabled={breakDisabled}
onPress={onBreakPress}
>
<Text className="text-xl font-bold text-white">
{breakButtonLabel}
</Text>
</TouchableOpacity>

{!!monthlyCapMessage && (
<View className="mt-3 rounded-xl border border-rose-300 bg-rose-50 px-3 py-2">
<Text className="text-xs font-semibold text-rose-700">
{monthlyCapMessage}
</Text>
</View>
)}
</View>
)}
</View>
</View>
);
}

export default React.memo(AttendanceActionForm);
23 changes: 23 additions & 0 deletions components/AttendanceAction/BreakInProgressBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from "react";
import { View, Text } from "react-native";

function BreakInProgressBanner({ liveBreakTime }) {
return (
<View className="mb-3 rounded-2xl bg-amber-500 px-4 py-1">
<Text className="text-center text-xs font-semibold tracking-widest text-amber-100">
BREAK IN PROGRESS
</Text>
<Text
className="mt-1 text-center text-2xl font-extrabold text-white"
style={{ fontVariant: ["tabular-nums"] }}
>
{liveBreakTime || "00:00:00"}
</Text>
<Text className="mt-1 text-center text-xs text-amber-100">
Auto-ends at 02:00:00
</Text>
</View>
);
}

export default React.memo(BreakInProgressBanner);
62 changes: 62 additions & 0 deletions components/AttendanceAction/DevBreakTools.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";

const PRESETS = [
{ key: "idle-0", label: "Idle 00:00", tone: "bg-slate-700" },
{ key: "idle-45", label: "Idle 00:45", tone: "bg-slate-700" },
{ key: "running-30", label: "Running +30m", tone: "bg-amber-700" },
{ key: "cap-120", label: "Cap 02:00", tone: "bg-gray-700" },
{ key: "completed", label: "Completed 1/day", tone: "bg-indigo-700" },
{ key: "monthly-cap", label: "Monthly Cap 8h", tone: "bg-rose-700" },
];

function DevBreakTools({
devBreakMockMode,
onToggleDevBreakMockMode,
onInvalidateAccessToken,
onApplyPreset,
}) {
return (
<View className="mt-4 rounded-2xl border border-red-200 bg-red-50 p-3">
<TouchableOpacity
className="rounded-2xl bg-red-600 px-4 py-3 items-center"
onPress={onInvalidateAccessToken}
>
<Text className="text-white font-bold">
DEV: Invalidate access token
</Text>
</TouchableOpacity>

<Text className="mt-3 text-xs font-bold uppercase tracking-wide text-red-700">
DEV: Break UI presets
</Text>

<TouchableOpacity
className={`mt-2 rounded-xl px-3 py-2 ${
devBreakMockMode ? "bg-emerald-700" : "bg-slate-700"
}`}
onPress={onToggleDevBreakMockMode}
>
<Text className="text-xs font-semibold text-white">
DEV local break flow: {devBreakMockMode ? "ON" : "OFF"}
</Text>
</TouchableOpacity>

<View className="mt-2 flex-row flex-wrap">
{PRESETS.map((preset) => (
<TouchableOpacity
key={preset.key}
className={`mb-2 mr-2 rounded-xl px-3 py-2 ${preset.tone}`}
onPress={() => onApplyPreset(preset.key)}
>
<Text className="text-xs font-semibold text-white">
{preset.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}

export default React.memo(DevBreakTools);
30 changes: 30 additions & 0 deletions hooks/attendanceAction/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const BREAK_LIMIT_MS = 2 * 60 * 60 * 1000; // 2 hours

export const getTodayString = () =>
new Date().toLocaleDateString("en-GB").replace(/\//g, "-");

export const isBreakCompleted = (breakData) => {
if (!breakData?.breaks?.length) return false;

const hasIn = breakData.breaks.some((b) => b.start);
const hasOut = breakData.breaks.some((b) => b.end);

return hasIn && hasOut;
};

export const findOpenBreak = (breakData) => {
const breaks = breakData?.breaks ?? [];
for (let i = breaks.length - 1; i >= 0; i -= 1) {
const currentBreak = breaks[i];
if (!currentBreak?.end) return currentBreak;
}
return null;
};

export const formatSecondsToHms = (seconds) => {
const hrs = String(Math.floor(seconds / 3600)).padStart(2, "0");
const mins = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
const secs = String(seconds % 60).padStart(2, "0");

return `${hrs}:${mins}:${secs}`;
};
Loading