}) => endpoint.call(),
+ cancelEndPoint: vi.fn(),
+ }),
}));
const LocationTracker = () => {
@@ -29,6 +55,7 @@ const renderAtPath = (path: string) =>
describe("ViewSwitcher", () => {
beforeEach(() => {
mockDirection.direction = "rtl";
+ mockAuthState.user = null;
vi.clearAllMocks();
});
@@ -62,6 +89,16 @@ describe("ViewSwitcher", () => {
renderAtPath("/he/daily");
expect(screen.getByRole("button", { name: "Open navigation menu" })).toBeInTheDocument();
});
+
+ it("renders sign out only when a user is authenticated", () => {
+ mockAuthState.user = { email: "worker@example.com" };
+
+ renderAtPath("/he/daily");
+
+ expect(screen.getByRole("button", { name: "התנתקות" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "התחברות" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "הרשמה" })).not.toBeInTheDocument();
+ });
});
describe("Toggle button label", () => {
diff --git a/src/test/ui/providers/AppProviders.test.tsx b/src/test/ui/providers/AppProviders.test.tsx
index 1aab342..305653a 100644
--- a/src/test/ui/providers/AppProviders.test.tsx
+++ b/src/test/ui/providers/AppProviders.test.tsx
@@ -17,6 +17,10 @@ vi.mock("@/app/providers/snackbar/AppSnackbarProvider", () => ({
AppSnackbarProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
}));
+vi.mock("@/app/providers/auth/AuthProvider", () => ({
+ AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+
import { AppProviders } from "@/app/providers/AppProviders";
describe("AppProviders", () => {
diff --git a/src/test/ui/providers/AuthProvider.test.tsx b/src/test/ui/providers/AuthProvider.test.tsx
new file mode 100644
index 0000000..c563979
--- /dev/null
+++ b/src/test/ui/providers/AuthProvider.test.tsx
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import type { AuthChangeEvent, Session } from "@supabase/supabase-js";
+
+const authMocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ onAuthStateChange: vi.fn(),
+ unsubscribe: vi.fn(),
+ callback: null as ((event: AuthChangeEvent, session: Session | null) => void) | null,
+}));
+
+vi.mock("@/services/supabase/supabase.client", () => ({
+ supabase: {
+ auth: {
+ getSession: authMocks.getSession,
+ onAuthStateChange: authMocks.onAuthStateChange,
+ },
+ },
+}));
+
+import { AuthProvider } from "@/app/providers/auth/AuthProvider";
+import { useAuth } from "@/hooks/useAuth";
+
+const AuthStateProbe = () => {
+ const { user, isLoading } = useAuth();
+
+ return {isLoading ? "loading" : user?.email ?? "signed-out"}
;
+};
+
+describe("AuthProvider", () => {
+ it("loads the existing session and unsubscribes on unmount", async () => {
+ const session = {
+ user: { email: "worker@example.com" },
+ } as Session;
+
+ authMocks.getSession.mockResolvedValue({
+ data: { session },
+ error: null,
+ });
+ authMocks.onAuthStateChange.mockImplementation((callback) => {
+ authMocks.callback = callback;
+ return { data: { subscription: { unsubscribe: authMocks.unsubscribe } } };
+ });
+
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("worker@example.com")).toBeInTheDocument();
+ });
+
+ unmount();
+ expect(authMocks.unsubscribe).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/utils/axios-response.resolve.util.ts b/src/utils/axios-response.resolve.util.ts
new file mode 100644
index 0000000..9b54495
--- /dev/null
+++ b/src/utils/axios-response.resolve.util.ts
@@ -0,0 +1,14 @@
+import type { AxiosResponse } from "axios";
+import { ApiResponse } from "@/domain";
+import { resolveErrorMessage } from "./axios-error.resolve.util";
+
+export const toApiResponse = async (
+ request: Promise>,
+): Promise> => {
+ try {
+ const response = await request;
+ return { data: response.data };
+ } catch (err: unknown) {
+ return { error: resolveErrorMessage(err) };
+ }
+};
diff --git a/src/utils/index.ts b/src/utils/index.ts
index a0e0c11..fa2427b 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,3 +1,5 @@
export * from "./helpers.util";
export { resolveErrorMessage } from "./axios-error.resolve.util";
+export { toApiResponse } from "./axios-response.resolve.util";
+export { fromSupabaseResult } from "./supabase-result.resolve.util";
diff --git a/src/utils/supabase-result.resolve.util.ts b/src/utils/supabase-result.resolve.util.ts
new file mode 100644
index 0000000..7b4b126
--- /dev/null
+++ b/src/utils/supabase-result.resolve.util.ts
@@ -0,0 +1,7 @@
+import { ApiResponse } from "@/domain";
+
+export const fromSupabaseResult = (result: {
+ data: unknown;
+ error: { message: string } | null;
+}): ApiResponse =>
+ result.error ? { error: result.error.message } : { data: result.data as T };
diff --git a/supabase/migrations/20260830000000_persistence_schema.sql b/supabase/migrations/20260830000000_persistence_schema.sql
new file mode 100644
index 0000000..4cff6b0
--- /dev/null
+++ b/supabase/migrations/20260830000000_persistence_schema.sql
@@ -0,0 +1,53 @@
+-- Per-user persistence for monthly config, day status, and shifts.
+-- Run this in the Supabase SQL Editor (Dashboard -> SQL Editor).
+
+create table public.monthly_configs (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ year integer not null,
+ month integer not null check (month between 1 and 12),
+ standard_hours numeric not null default 6.67,
+ base_rate numeric not null default 0,
+ updated_at timestamptz not null default now(),
+ unique (user_id, year, month)
+);
+
+create table public.work_days (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ date date not null,
+ status text not null default 'normal' check (status in ('normal', 'vacation', 'sick')),
+ updated_at timestamptz not null default now(),
+ unique (user_id, date)
+);
+
+create table public.shifts (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ date date not null,
+ start_time timestamptz not null,
+ end_time timestamptz not null,
+ is_duty boolean not null default false,
+ updated_at timestamptz not null default now()
+);
+
+create index shifts_user_date_idx on public.shifts(user_id, date);
+
+alter table public.monthly_configs enable row level security;
+alter table public.work_days enable row level security;
+alter table public.shifts enable row level security;
+
+create policy "Users manage their own monthly configs"
+ on public.monthly_configs for all
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);
+
+create policy "Users manage their own work days"
+ on public.work_days for all
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);
+
+create policy "Users manage their own shifts"
+ on public.shifts for all
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);
diff --git a/supabase/migrations/20260830000001_shabbat_credit_carry_over.sql b/supabase/migrations/20260830000001_shabbat_credit_carry_over.sql
new file mode 100644
index 0000000..dac4578
--- /dev/null
+++ b/supabase/migrations/20260830000001_shabbat_credit_carry_over.sql
@@ -0,0 +1,5 @@
+-- Adds the running Shabbat-credit carry-over balance to monthly_configs.
+-- Run this in the Supabase SQL Editor (Dashboard -> SQL Editor).
+
+alter table public.monthly_configs
+ add column unused_shabbat_credit_hours numeric not null default 0;