From 230cb367d5724cc544b8f7b030e4c9deca030ac9 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:13:14 +0300 Subject: [PATCH 1/9] feat(auth): add Google sign-in via Supabase --- bun.lock | 17 +++ package.json | 1 + src/app/providers/AppProviders.tsx | 16 +-- src/app/providers/auth/AuthProvider.tsx | 63 +++++++++++ src/app/providers/auth/authContext.ts | 11 ++ src/app/providers/index.ts | 3 + src/features/auth/AuthControls.tsx | 115 ++++++++++++++++++++ src/features/auth/index.ts | 1 + src/features/index.ts | 1 + src/hooks/index.ts | 2 +- src/hooks/useAuth.ts | 13 +++ src/i18n/locales/en/common.json | 8 ++ src/i18n/locales/he/common.json | 8 ++ src/layout/ViewSwitcher.tsx | 3 + src/pages/CalculationRulesPage.tsx | 4 + src/pages/DailyPage.tsx | 13 +-- src/pages/MonthlySummaryPage.tsx | 10 +- src/services/supabase/supabase.client.ts | 10 ++ src/test/ui/features/AuthControls.test.tsx | 112 +++++++++++++++++++ src/test/ui/layout/ViewSwitcher.test.tsx | 37 +++++++ src/test/ui/providers/AppProviders.test.tsx | 4 + src/test/ui/providers/AuthProvider.test.tsx | 58 ++++++++++ src/utils/index.ts | 1 + src/utils/supabase-result.resolve.util.ts | 7 ++ 24 files changed, 502 insertions(+), 16 deletions(-) create mode 100644 src/app/providers/auth/AuthProvider.tsx create mode 100644 src/app/providers/auth/authContext.ts create mode 100644 src/features/auth/AuthControls.tsx create mode 100644 src/features/auth/index.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/services/supabase/supabase.client.ts create mode 100644 src/test/ui/features/AuthControls.test.tsx create mode 100644 src/test/ui/providers/AuthProvider.test.tsx create mode 100644 src/utils/supabase-result.resolve.util.ts diff --git a/bun.lock b/bun.lock index 2604ff9..cb1d476 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@mui/material": "^7.0.2", "@mui/x-date-pickers": "^8.1.0", "@reduxjs/toolkit": "^2.11.0", + "@supabase/supabase-js": "^2.112.4", "axios": "^1.18.1", "date-fns": "^4.1.0", "i18next": "^26.0.4", @@ -353,6 +354,20 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@supabase/auth-js": ["@supabase/auth-js@2.112.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-z8DesgwLzKM5PiT0yNmJU8VJyh1zAhYi+20Z7drdJQLXg/wWW4yGt/un+He5ERYUo94Vz66t5aeyr1DIDemI5A=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.112.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.5", "", {}, "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.112.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.112.4", "", { "dependencies": { "@supabase/phoenix": "0.4.5", "tslib": "2.8.1" } }, "sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.112.4", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.112.4", "", { "dependencies": { "@supabase/auth-js": "2.112.4", "@supabase/functions-js": "2.112.4", "@supabase/postgrest-js": "2.112.4", "@supabase/realtime-js": "2.112.4", "@supabase/storage-js": "2.112.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-UiCX1udlFY1fQQrO7Z3GU7obQsju0w5Vk9mOOwalfo/+Gy+tahWVenSSuu5E/GTy/q//HxvGv2IrCdW66/61kw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -675,6 +690,8 @@ "i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" } }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/package.json b/package.json index b469feb..b1008fb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@mui/material": "^7.0.2", "@mui/x-date-pickers": "^8.1.0", "@reduxjs/toolkit": "^2.11.0", + "@supabase/supabase-js": "^2.112.4", "axios": "^1.18.1", "date-fns": "^4.1.0", "i18next": "^26.0.4", diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index 4262cc8..ee8d9cb 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -6,7 +6,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { store } from "@/redux/store"; -import { DomainProvider, AppSnackbarProvider } from "@/app/providers"; +import { AuthProvider, DomainProvider, AppSnackbarProvider } from "@/app/providers"; import createCache from "@emotion/cache"; import rtlPlugin from "stylis-plugin-rtl"; @@ -40,12 +40,14 @@ export const AppProviders = ({ children }: AppProvidersProps) => { - - - - {children} - - + + + + + {children} + + + diff --git a/src/app/providers/auth/AuthProvider.tsx b/src/app/providers/auth/AuthProvider.tsx new file mode 100644 index 0000000..7be2a81 --- /dev/null +++ b/src/app/providers/auth/AuthProvider.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Session } from "@supabase/supabase-js"; + +import { supabase } from "@/services/supabase/supabase.client"; +import { AuthContext, type AuthContextValue } from "./authContext"; + +type AuthProviderProps = { + children: React.ReactNode; +}; + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const [session, setSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [initializationError, setInitializationError] = useState( + null, + ); + + useEffect(() => { + let isMounted = true; + let receivedAuthEvent = false; + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, nextSession) => { + if (!isMounted) return; + + receivedAuthEvent = true; + setSession(nextSession); + setInitializationError(null); + setIsLoading(false); + }); + + void supabase.auth.getSession().then(({ data, error }) => { + if (!isMounted || receivedAuthEvent) return; + + if (error) { + setInitializationError(error.message); + setSession(null); + } else { + setSession(data.session); + } + + setIsLoading(false); + }); + + return () => { + isMounted = false; + subscription.unsubscribe(); + }; + }, []); + + const value = useMemo( + () => ({ + session, + user: session?.user ?? null, + isLoading, + initializationError, + }), + [session, isLoading, initializationError], + ); + + return {children}; +}; diff --git a/src/app/providers/auth/authContext.ts b/src/app/providers/auth/authContext.ts new file mode 100644 index 0000000..d773c54 --- /dev/null +++ b/src/app/providers/auth/authContext.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; +import type { Session, User } from "@supabase/supabase-js"; + +export type AuthContextValue = { + session: Session | null; + user: User | null; + isLoading: boolean; + initializationError: string | null; +}; + +export const AuthContext = createContext(null); diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts index f0b1817..5ce5b7c 100644 --- a/src/app/providers/index.ts +++ b/src/app/providers/index.ts @@ -5,3 +5,6 @@ export { DomainContext } from "./domain/domainContext"; export { AppSnackbarContext } from "./snackbar/snackbarContext"; export { DirectionContext } from "./direction/directionContext"; export type { Direction } from "./direction/directionContext"; +export { AuthProvider } from "./auth/AuthProvider"; +export { AuthContext } from "./auth/authContext"; +export type { AuthContextValue } from "./auth/authContext"; diff --git a/src/features/auth/AuthControls.tsx b/src/features/auth/AuthControls.tsx new file mode 100644 index 0000000..d5dcc6e --- /dev/null +++ b/src/features/auth/AuthControls.tsx @@ -0,0 +1,115 @@ +import LogoutIcon from "@mui/icons-material/Logout"; +import { Alert, Button, CircularProgress, Stack, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { useAppSnackbar, useAuth, useFetch } from "@/hooks"; +import { supabase } from "@/services/supabase/supabase.client"; +import { fromSupabaseResult } from "@/utils"; + +type AuthControlsDisplay = "guest" | "account"; + +type AuthControlsProps = { + display?: AuthControlsDisplay; +}; + +const GoogleIcon = () => ( + +); + +export const AuthControls = ({ display = "guest" }: AuthControlsProps) => { + const { t } = useTranslation(); + const { user, isLoading, initializationError } = useAuth(); + const snackbar = useAppSnackbar(); + const { loading: signingIn, callEndPoint } = useFetch(); + + const handleSignInWithGoogle = async () => { + const result = await callEndPoint({ + call: async () => + fromSupabaseResult( + await supabase.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: window.location.href }, + }), + ), + }); + + if (result.error) snackbar.error(result.error); + }; + + const handleSignOut = async () => { + const { error } = await supabase.auth.signOut(); + + if (error) { + snackbar.error(error.message); + return; + } + + snackbar.success(t("auth.sign_out_success")); + }; + + if (isLoading) { + if (display === "account") return null; + + return ( + + + + {t("auth.loading")} + + + ); + } + + if (display === "guest" && user) return null; + if (display === "account" && !user) return null; + + if (display === "account") { + return ( + + ); + } + + return ( + + {initializationError && {t("auth.initialization_error")}} + + + {t("auth.sign_in_benefit")} + + + + + ); +}; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts new file mode 100644 index 0000000..77e9eb6 --- /dev/null +++ b/src/features/auth/index.ts @@ -0,0 +1 @@ +export { AuthControls } from "./AuthControls"; diff --git a/src/features/index.ts b/src/features/index.ts index d1e104c..88cb316 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -6,3 +6,4 @@ export * from "./info-dialog"; export * from "./workday-timeline"; export * from "./feedback"; +export * from "./auth"; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 64a191b..23f523b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,4 +8,4 @@ export { useAppSnackbar } from "./useAppSnackbar"; export { useDebounce } from "./useDebounce"; export { useDeviceType } from "./useDeviceType"; export { usePageTracking } from "./usePageTracking"; -export { useShabbatCreditAllocation } from "./useShabbatCreditAllocation"; +export { useAuth } from "./useAuth"; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..ab434e8 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import { AuthContext } from "@/app/providers/auth/authContext"; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + + return context; +}; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 1e137e5..67b5043 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -31,6 +31,14 @@ "not_sure": "Not sure", "success": "Thank you! Feedback received" }, + "auth": { + "sign_in_benefit": "Sign in for free to save your shifts and settings, and access them from any device.", + "sign_in_with_google": "Continue with Google", + "sign_out": "Sign out", + "loading": "Loading account...", + "initialization_error": "Your account session could not be loaded. Please try again.", + "sign_out_success": "You are signed out." + }, "actions": { "try_again": "Try Again", "refresh_page": "Refresh Page", diff --git a/src/i18n/locales/he/common.json b/src/i18n/locales/he/common.json index 0d18de1..66cad9d 100644 --- a/src/i18n/locales/he/common.json +++ b/src/i18n/locales/he/common.json @@ -31,6 +31,14 @@ "not_sure": "לא בטוח", "success": "תודה! המשוב נקלט" }, + "auth": { + "sign_in_benefit": "ההתחברות חינמית — היא שומרת את המשמרות וההגדרות שלך, ומאפשרת גישה אליהן מכל מכשיר.", + "sign_in_with_google": "המשך עם Google", + "sign_out": "התנתקות", + "loading": "טוען חשבון...", + "initialization_error": "לא ניתן לטעון את החשבון. יש לנסות שוב.", + "sign_out_success": "התנתקת בהצלחה." + }, "actions": { "try_again": "נסה שוב", "refresh_page": "רענן דף", diff --git a/src/layout/ViewSwitcher.tsx b/src/layout/ViewSwitcher.tsx index fc18d17..10b68ab 100644 --- a/src/layout/ViewSwitcher.tsx +++ b/src/layout/ViewSwitcher.tsx @@ -20,6 +20,7 @@ import { import { useTranslation } from "react-i18next"; import { useDirection } from "@/hooks"; import { analyticsService } from "@/services"; +import { AuthControls } from "@/features/auth"; const navButtonBaseStyle = { color: "text.secondary", @@ -100,6 +101,8 @@ export const ViewSwitcher = () => { + + {/* Language toggle */} { > {t(`${cr}.sub_title`)} + + + diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index d446a4a..034b11b 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -15,17 +15,13 @@ import { useTranslation } from "react-i18next"; import { WorkTable, + AuthControls, ConfigPanel, MonthlySalarySummary, Feedback, -} from "@/features"; -import { - useFetch, - useGlobalState, useShabbatCreditAllocation, - useWorkDays, - useAsync, -} from "@/hooks"; +} from "@/features"; +import { useFetch, useGlobalState, useWorkDays, useAsync } from "@/hooks"; import { ApiResponse, CalendarEventMap } from "@/domain"; import { buildEventMap } from "@/adapters"; import { DomainContextType } from "@/app"; @@ -135,6 +131,9 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => { {t("nav_link_rules")} + + + } sx={{ diff --git a/src/pages/MonthlySummaryPage.tsx b/src/pages/MonthlySummaryPage.tsx index 8cdc289..66bd39e 100644 --- a/src/pages/MonthlySummaryPage.tsx +++ b/src/pages/MonthlySummaryPage.tsx @@ -11,7 +11,12 @@ import { Link as RouterLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { DomainContextType } from "@/app"; -import { ConfigPanel, MonthlySalarySummary, Feedback } from "@/features"; +import { + AuthControls, + ConfigPanel, + MonthlySalarySummary, + Feedback, +} from "@/features"; import { ErrorBoundary, FeatureErrorFallback } from "@/layout"; export const MonthlySummaryPage = ({ @@ -71,6 +76,9 @@ export const MonthlySummaryPage = ({ {t("monthly_summary_page.nav_link_rules")} + + + } sx={{ diff --git a/src/services/supabase/supabase.client.ts b/src/services/supabase/supabase.client.ts new file mode 100644 index 0000000..e76cf60 --- /dev/null +++ b/src/services/supabase/supabase.client.ts @@ -0,0 +1,10 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; + +if (!supabaseUrl || !supabasePublishableKey) { + throw new Error("Supabase environment variables are not configured."); +} + +export const supabase = createClient(supabaseUrl, supabasePublishableKey); diff --git a/src/test/ui/features/AuthControls.test.tsx b/src/test/ui/features/AuthControls.test.tsx new file mode 100644 index 0000000..2b210d9 --- /dev/null +++ b/src/test/ui/features/AuthControls.test.tsx @@ -0,0 +1,112 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import i18n from "@/i18n"; + +const authMocks = vi.hoisted(() => ({ + signInWithOAuth: vi.fn(), + signOut: vi.fn(), +})); + +const hookMocks = vi.hoisted(() => ({ + authState: { + user: null as { email?: string } | null, + isLoading: false, + initializationError: null as string | null, + }, + snackbar: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("@/services/supabase/supabase.client", () => ({ + supabase: { + auth: authMocks, + }, +})); + +vi.mock("@/hooks", () => ({ + useAuth: () => hookMocks.authState, + useAppSnackbar: () => hookMocks.snackbar, + useFetch: () => ({ + loading: false, + callEndPoint: (endpoint: { call: () => Promise }) => endpoint.call(), + cancelEndPoint: vi.fn(), + }), +})); + +import { AuthControls } from "@/features/auth/AuthControls"; + +describe("AuthControls", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + hookMocks.authState.user = null; + hookMocks.authState.isLoading = false; + hookMocks.authState.initializationError = null; + vi.clearAllMocks(); + }); + + afterAll(async () => { + await i18n.changeLanguage("he"); + }); + + it("starts the Google OAuth flow", async () => { + const user = userEvent.setup(); + authMocks.signInWithOAuth.mockResolvedValue({ + data: { provider: "google", url: "https://accounts.google.com/o/oauth2/..." }, + error: null, + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Continue with Google" })); + + expect(authMocks.signInWithOAuth).toHaveBeenCalledWith({ + provider: "google", + options: { redirectTo: window.location.href }, + }); + expect(hookMocks.snackbar.error).not.toHaveBeenCalled(); + }); + + it("shows an error when the OAuth flow fails to start", async () => { + const user = userEvent.setup(); + authMocks.signInWithOAuth.mockResolvedValue({ + data: { provider: "google", url: null }, + error: { message: "Provider is not enabled" }, + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Continue with Google" })); + + expect(hookMocks.snackbar.error).toHaveBeenCalledWith("Provider is not enabled"); + }); + + it("shows the current user and signs out", async () => { + const user = userEvent.setup(); + hookMocks.authState.user = { email: "worker@example.com" }; + authMocks.signOut.mockResolvedValue({ error: null }); + + render(); + + await user.click(screen.getByRole("button", { name: "Sign out" })); + + expect(authMocks.signOut).toHaveBeenCalledOnce(); + expect(hookMocks.snackbar.success).toHaveBeenCalledWith("You are signed out."); + }); + + it("hides page authentication controls from signed-in users", () => { + hookMocks.authState.user = { email: "worker@example.com" }; + + render(); + + expect( + screen.queryByRole("button", { name: "Continue with Google" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign out" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/ui/layout/ViewSwitcher.test.tsx b/src/test/ui/layout/ViewSwitcher.test.tsx index be3bd69..f262496 100644 --- a/src/test/ui/layout/ViewSwitcher.test.tsx +++ b/src/test/ui/layout/ViewSwitcher.test.tsx @@ -8,9 +8,35 @@ import type { Direction } from "@/app/providers/direction/directionContext"; type MockDirection = { direction: Direction; setDirection: ReturnType }; const mockDirection: MockDirection = { direction: "rtl", setDirection: vi.fn() }; +const mockAuthState = { + user: null as { email?: string } | null, + isLoading: false, + initializationError: null, +}; +const mockSnackbar = { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +}; + +vi.mock("@/services/supabase/supabase.client", () => ({ + supabase: { + auth: { + signOut: vi.fn(), + }, + }, +})); vi.mock("@/hooks", () => ({ useDirection: () => mockDirection, + useAuth: () => mockAuthState, + useAppSnackbar: () => mockSnackbar, + useFetch: () => ({ + loading: false, + callEndPoint: (endpoint: { call: () => Promise }) => 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/index.ts b/src/utils/index.ts index a0e0c11..a1d1bac 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./helpers.util"; export { resolveErrorMessage } from "./axios-error.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 }; From af428d14db58255bbe8971e156f3e1f0dc82cca1 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:16:48 +0300 Subject: [PATCH 2/9] refactor(fetch): generalize useFetch beyond Axios --- src/hooks/useFetch.ts | 22 +++++++++++----------- src/pages/DailyPage.tsx | 5 +---- src/services/hebcal/hebcal.service.ts | 3 ++- src/utils/axios-response.resolve.util.ts | 14 ++++++++++++++ src/utils/index.ts | 1 + 5 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 src/utils/axios-response.resolve.util.ts diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index 722d4d6..f9699aa 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { AxiosResponse } from "axios"; import { ApiResponse } from "@/domain"; import { resolveErrorMessage } from "@/utils"; -interface AxiosCall { - call: () => Promise>; +export interface EndpointCall { + call: () => Promise>; controller?: AbortController; } @@ -13,18 +12,19 @@ export const useFetch = () => { const controllerRef = useRef(undefined); const callEndPoint = useCallback( - async ( - axiosCall: AxiosCall, - adapter?: (raw: unknown) => T, - ): Promise> => { - if (axiosCall.controller) controllerRef.current = axiosCall.controller; + async ( + endpoint: EndpointCall, + adapter?: (raw: T) => R, + ): Promise> => { + if (endpoint.controller) controllerRef.current = endpoint.controller; setLoading(true); try { - const result = await axiosCall.call(); - const data = adapter ? adapter(result.data) : (result.data as T); - return { data }; + const result = await endpoint.call(); + if (result.error) return { error: result.error }; + const raw = result.data as T; + return { data: adapter ? adapter(raw) : (raw as unknown as R) }; } catch (err: unknown) { return { error: resolveErrorMessage(err) }; } finally { diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index 034b11b..5be4c2f 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -70,10 +70,7 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => { useAsync>( () => { const { startDate, endDate } = dateService.getDatesRange(year, month); - return callEndPoint( - calendarApi.getData(startDate, endDate), - buildEventMap, - ); + return callEndPoint(calendarApi.getData(startDate, endDate), buildEventMap); }, [dateService, year, month, callEndPoint], handleCalendarResult, diff --git a/src/services/hebcal/hebcal.service.ts b/src/services/hebcal/hebcal.service.ts index 99ddf47..c2466ef 100644 --- a/src/services/hebcal/hebcal.service.ts +++ b/src/services/hebcal/hebcal.service.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { toApiResponse } from "@/utils"; export const hebcalService = () => { const loadAbort = () => new AbortController(); @@ -23,7 +24,7 @@ export const hebcalService = () => { const controller = loadAbort(); const url = buildUrl(start, end); return { - call: () => axios.get(url, { signal: controller.signal }), + call: () => toApiResponse(axios.get(url, { signal: controller.signal })), controller, }; }; 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 a1d1bac..fa2427b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +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"; From b92310cad27f72e085efadc911846ddacce8a915 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:18:49 +0300 Subject: [PATCH 3/9] feat(config): persist monthly config, incl. Shabbat-credit carry-over column --- src/features/config/hooks/index.ts | 1 + .../config/hooks/useMonthlyConfigSync.ts | 76 +++++++++ src/features/config/index.ts | 1 + src/hooks/useDebounce.ts | 6 +- src/hooks/useGlobalState.ts | 59 ++++--- src/layout/Layout.tsx | 3 + src/services/index.ts | 2 + .../monthlyConfig/monthlyConfig.service.ts | 64 ++++++++ .../services/monthlyConfig.service.test.ts | 124 ++++++++++++++ .../ui/features/useMonthlyConfigSync.test.tsx | 155 ++++++++++++++++++ .../20260830000000_persistence_schema.sql | 53 ++++++ ...260830000001_shabbat_credit_carry_over.sql | 5 + 12 files changed, 526 insertions(+), 23 deletions(-) create mode 100644 src/features/config/hooks/index.ts create mode 100644 src/features/config/hooks/useMonthlyConfigSync.ts create mode 100644 src/services/monthlyConfig/monthlyConfig.service.ts create mode 100644 src/test/services/monthlyConfig.service.test.ts create mode 100644 src/test/ui/features/useMonthlyConfigSync.test.tsx create mode 100644 supabase/migrations/20260830000000_persistence_schema.sql create mode 100644 supabase/migrations/20260830000001_shabbat_credit_carry_over.sql diff --git a/src/features/config/hooks/index.ts b/src/features/config/hooks/index.ts new file mode 100644 index 0000000..1d87a3b --- /dev/null +++ b/src/features/config/hooks/index.ts @@ -0,0 +1 @@ +export { useMonthlyConfigSync } from "./useMonthlyConfigSync"; diff --git a/src/features/config/hooks/useMonthlyConfigSync.ts b/src/features/config/hooks/useMonthlyConfigSync.ts new file mode 100644 index 0000000..dca2cb7 --- /dev/null +++ b/src/features/config/hooks/useMonthlyConfigSync.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef } from "react"; + +import { useAppSnackbar } from "@/hooks/useAppSnackbar"; +import { useAuth } from "@/hooks/useAuth"; +import { useDebounce } from "@/hooks/useDebounce"; +import { useFetch } from "@/hooks/useFetch"; +import { useGlobalState } from "@/hooks/useGlobalState"; +import { monthlyConfigService } from "@/services"; + +export const useMonthlyConfigSync = () => { + const { user } = useAuth(); + const { year, month, standardHours, baseRate, updateStandardHours, updateBaseRate } = + useGlobalState(); + const { callEndPoint } = useFetch(); + const snackbar = useAppSnackbar(); + + // Guards the write-through effect from firing before hydration resolves for + // the current (user, year, month), so it never overwrites a persisted + // config with the in-memory defaults on first render. + const hydratedKeyRef = useRef(null); + + useEffect(() => { + if (!user) { + hydratedKeyRef.current = null; + return; + } + + const key = `${user.id}:${year}:${month}`; + if (hydratedKeyRef.current === key) return; + + let cancelled = false; + + void callEndPoint(monthlyConfigService().fetch(user.id, year, month)).then((result) => { + if (cancelled) return; + hydratedKeyRef.current = key; + + if (result.error) { + snackbar.error(result.error); + return; + } + + if (result.data) { + updateStandardHours(result.data.standard_hours); + updateBaseRate(result.data.base_rate); + } + }); + + return () => { + cancelled = true; + }; + // snackbar isn't referentially stable (AppSnackbarProvider doesn't memoize + // its context value), so it's read via the closure instead of listed here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, year, month, callEndPoint, updateStandardHours, updateBaseRate]); + + const debouncedStandardHours = useDebounce({ value: standardHours }); + const debouncedBaseRate = useDebounce({ value: baseRate }); + + useEffect(() => { + if (!user) return; + if (hydratedKeyRef.current !== `${user.id}:${year}:${month}`) return; + + void callEndPoint( + monthlyConfigService().upsert(user.id, { + year, + month, + standard_hours: debouncedStandardHours, + base_rate: debouncedBaseRate, + }), + ).then((result) => { + if (result.error) snackbar.error(result.error); + }); + // snackbar isn't referentially stable (see comment above) - read via closure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, year, month, debouncedStandardHours, debouncedBaseRate, callEndPoint]); +}; diff --git a/src/features/config/index.ts b/src/features/config/index.ts index efe9308..1cbe092 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -1,2 +1,3 @@ export { ConfigInput } from "./ConfigInput"; export { ConfigPanel } from "./ConfigPanel"; +export * from "./hooks"; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 3082c8d..faf1b6a 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; -type DebounceProps = { - value: string; +type DebounceProps = { + value: T; delay?: number; }; -export const useDebounce = ({ value, delay = 500 }: DebounceProps) => { +export const useDebounce = ({ value, delay = 500 }: DebounceProps) => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { diff --git a/src/hooks/useGlobalState.ts b/src/hooks/useGlobalState.ts index c758ef4..c78fded 100644 --- a/src/hooks/useGlobalState.ts +++ b/src/hooks/useGlobalState.ts @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { RootState, AppDispatch } from "@/redux/store"; @@ -33,33 +34,51 @@ export const useGlobalState = () => { (state: RootState) => state.global.dailyPayMaps, ); - const updateYear = (year: number) => { - dispatch(setYear(year)); - }; + const updateYear = useCallback( + (year: number) => { + dispatch(setYear(year)); + }, + [dispatch], + ); - const updateMonth = (month: number) => { - dispatch(setMonth(month)); - }; + const updateMonth = useCallback( + (month: number) => { + dispatch(setMonth(month)); + }, + [dispatch], + ); - const updateStandardHours = (hours: number) => { - dispatch(setStandardHours(hours)); - }; + const updateStandardHours = useCallback( + (hours: number) => { + dispatch(setStandardHours(hours)); + }, + [dispatch], + ); - const updateBaseRate = (rate: number) => { - dispatch(setBaseRate(rate)); - }; + const updateBaseRate = useCallback( + (rate: number) => { + dispatch(setBaseRate(rate)); + }, + [dispatch], + ); - const addDay = (dateKey: string, dayPayMap: WorkDayMap) => { - dispatch(addDayPayMap({ dateKey, dayPayMap })); - }; + const addDay = useCallback( + (dateKey: string, dayPayMap: WorkDayMap) => { + dispatch(addDayPayMap({ dateKey, dayPayMap })); + }, + [dispatch], + ); - const removeDay = (dateKey: string) => { - dispatch(removeDayPayMap(dateKey)); - }; + const removeDay = useCallback( + (dateKey: string) => { + dispatch(removeDayPayMap(dateKey)); + }, + [dispatch], + ); - const reset = () => { + const reset = useCallback(() => { dispatch(resetGlobal()); - }; + }, [dispatch]); return { // state diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx index aa3f643..8556374 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -1,7 +1,10 @@ import { Footer, ViewSwitcher } from "@/layout"; import { Box } from "@mui/material"; +import { useMonthlyConfigSync } from "@/features/config"; export const Layout = ({ children }: { children: React.ReactNode }) => { + useMonthlyConfigSync(); + return ( { + const fetch = (userId: string, year: number, month: number) => ({ + call: async () => + fromSupabaseResult( + await supabase + .from("monthly_configs") + .select("year, month, standard_hours, base_rate, unused_shabbat_credit_hours") + .eq("user_id", userId) + .eq("year", year) + .eq("month", month) + .maybeSingle(), + ), + }); + + const upsert = ( + userId: string, + record: Pick, + ) => ({ + call: async () => + fromSupabaseResult( + await supabase.from("monthly_configs").upsert( + { user_id: userId, ...record, updated_at: new Date().toISOString() }, + { onConflict: "user_id,year,month" }, + ), + ), + }); + + // Written by the salary-summary feature's carry-over sync, kept separate + // from `upsert` (user-edited settings) since this value is derived from the + // month's calculation, not typed by the user. + const setUnusedShabbatCreditHours = ( + userId: string, + year: number, + month: number, + hours: number, + ) => ({ + call: async () => + fromSupabaseResult( + await supabase.from("monthly_configs").upsert( + { + user_id: userId, + year, + month, + unused_shabbat_credit_hours: hours, + updated_at: new Date().toISOString(), + }, + { onConflict: "user_id,year,month" }, + ), + ), + }); + + return { fetch, upsert, setUnusedShabbatCreditHours }; +}; diff --git a/src/test/services/monthlyConfig.service.test.ts b/src/test/services/monthlyConfig.service.test.ts new file mode 100644 index 0000000..b6676f2 --- /dev/null +++ b/src/test/services/monthlyConfig.service.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fromMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/supabase/supabase.client", () => ({ + supabase: { from: fromMock }, +})); + +import { monthlyConfigService } from "@/services/monthlyConfig/monthlyConfig.service"; + +describe("monthlyConfigService", () => { + beforeEach(() => { + fromMock.mockReset(); + }); + + it("fetches the persisted config for a user/year/month", async () => { + const maybeSingle = vi.fn().mockResolvedValue({ + data: { + year: 2026, + month: 8, + standard_hours: 6.67, + base_rate: 50, + unused_shabbat_credit_hours: 3.5, + }, + error: null, + }); + const eqMonth = vi.fn().mockReturnValue({ maybeSingle }); + const eqYear = vi.fn().mockReturnValue({ eq: eqMonth }); + const eqUser = vi.fn().mockReturnValue({ eq: eqYear }); + const select = vi.fn().mockReturnValue({ eq: eqUser }); + fromMock.mockReturnValue({ select }); + + const result = await monthlyConfigService().fetch("user-1", 2026, 8).call(); + + expect(fromMock).toHaveBeenCalledWith("monthly_configs"); + expect(select).toHaveBeenCalledWith( + "year, month, standard_hours, base_rate, unused_shabbat_credit_hours", + ); + expect(eqUser).toHaveBeenCalledWith("user_id", "user-1"); + expect(eqYear).toHaveBeenCalledWith("year", 2026); + expect(eqMonth).toHaveBeenCalledWith("month", 8); + expect(result).toEqual({ + data: { + year: 2026, + month: 8, + standard_hours: 6.67, + base_rate: 50, + unused_shabbat_credit_hours: 3.5, + }, + }); + }); + + it("returns null data when no config was ever saved for that month", async () => { + const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); + fromMock.mockReturnValue({ + select: () => ({ eq: () => ({ eq: () => ({ eq: () => ({ maybeSingle }) }) }) }), + }); + + const result = await monthlyConfigService().fetch("user-1", 2026, 8).call(); + + expect(result).toEqual({ data: null }); + }); + + it("returns an error when the fetch fails", async () => { + const maybeSingle = vi.fn().mockResolvedValue({ + data: null, + error: { message: "connection lost" }, + }); + fromMock.mockReturnValue({ + select: () => ({ eq: () => ({ eq: () => ({ eq: () => ({ maybeSingle }) }) }) }), + }); + + const result = await monthlyConfigService().fetch("user-1", 2026, 8).call(); + + expect(result).toEqual({ error: "connection lost" }); + }); + + it("upserts the config scoped to the user with the correct conflict target", async () => { + const upsert = vi.fn().mockResolvedValue({ data: null, error: null }); + fromMock.mockReturnValue({ upsert }); + + const record = { year: 2026, month: 8, standard_hours: 7, base_rate: 55 }; + const result = await monthlyConfigService().upsert("user-1", record).call(); + + expect(fromMock).toHaveBeenCalledWith("monthly_configs"); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ user_id: "user-1", ...record }), + { onConflict: "user_id,year,month" }, + ); + expect(result).toEqual({ data: null }); + }); + + it("returns an error when the upsert fails", async () => { + const upsert = vi.fn().mockResolvedValue({ data: null, error: { message: "denied" } }); + fromMock.mockReturnValue({ upsert }); + + const result = await monthlyConfigService() + .upsert("user-1", { year: 2026, month: 8, standard_hours: 7, base_rate: 55 }) + .call(); + + expect(result).toEqual({ error: "denied" }); + }); + + it("writes the unused Shabbat credit balance without touching other config fields", async () => { + const upsert = vi.fn().mockResolvedValue({ data: null, error: null }); + fromMock.mockReturnValue({ upsert }); + + const result = await monthlyConfigService() + .setUnusedShabbatCreditHours("user-1", 2026, 8, 3.5) + .call(); + + expect(fromMock).toHaveBeenCalledWith("monthly_configs"); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: "user-1", + year: 2026, + month: 8, + unused_shabbat_credit_hours: 3.5, + }), + { onConflict: "user_id,year,month" }, + ); + expect(result).toEqual({ data: null }); + }); +}); diff --git a/src/test/ui/features/useMonthlyConfigSync.test.tsx b/src/test/ui/features/useMonthlyConfigSync.test.tsx new file mode 100644 index 0000000..bd01f1a --- /dev/null +++ b/src/test/ui/features/useMonthlyConfigSync.test.tsx @@ -0,0 +1,155 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const authMock = vi.hoisted(() => ({ user: null as { id: string } | null })); + +const globalStateMock = vi.hoisted(() => ({ + year: 2026, + month: 8, + standardHours: 6.67, + baseRate: 0, + updateStandardHours: vi.fn(), + updateBaseRate: vi.fn(), +})); + +const snackbarMock = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +const serviceMock = vi.hoisted(() => ({ + fetch: vi.fn(), + upsert: vi.fn(), +})); + +vi.mock("@/hooks/useAuth", () => ({ useAuth: () => authMock })); +vi.mock("@/hooks/useGlobalState", () => ({ useGlobalState: () => globalStateMock })); +vi.mock("@/hooks/useAppSnackbar", () => ({ useAppSnackbar: () => snackbarMock })); +vi.mock("@/services", () => ({ monthlyConfigService: () => serviceMock })); + +import { useMonthlyConfigSync } from "@/features/config/hooks/useMonthlyConfigSync"; + +describe("useMonthlyConfigSync", () => { + beforeEach(() => { + vi.useFakeTimers(); + authMock.user = null; + globalStateMock.year = 2026; + globalStateMock.month = 8; + globalStateMock.standardHours = 6.67; + globalStateMock.baseRate = 0; + globalStateMock.updateStandardHours.mockReset(); + globalStateMock.updateBaseRate.mockReset(); + snackbarMock.error.mockReset(); + serviceMock.fetch.mockReset(); + serviceMock.upsert.mockReset(); + serviceMock.upsert.mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does nothing in guest mode", async () => { + renderHook(() => useMonthlyConfigSync()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(serviceMock.fetch).not.toHaveBeenCalled(); + expect(serviceMock.upsert).not.toHaveBeenCalled(); + }); + + it("hydrates the global config from the persisted record on mount", async () => { + authMock.user = { id: "user-1" }; + serviceMock.fetch.mockReturnValue({ + call: () => + Promise.resolve({ + data: { year: 2026, month: 8, standard_hours: 7.5, base_rate: 60 }, + }), + }); + + renderHook(() => useMonthlyConfigSync()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(serviceMock.fetch).toHaveBeenCalledWith("user-1", 2026, 8); + expect(globalStateMock.updateStandardHours).toHaveBeenCalledWith(7.5); + expect(globalStateMock.updateBaseRate).toHaveBeenCalledWith(60); + }); + + it("leaves the in-memory defaults untouched when nothing was ever saved", async () => { + authMock.user = { id: "user-1" }; + serviceMock.fetch.mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + + renderHook(() => useMonthlyConfigSync()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(globalStateMock.updateStandardHours).not.toHaveBeenCalled(); + expect(globalStateMock.updateBaseRate).not.toHaveBeenCalled(); + }); + + it("shows an error when hydration fails", async () => { + authMock.user = { id: "user-1" }; + serviceMock.fetch.mockReturnValue({ + call: () => Promise.resolve({ error: "connection lost" }), + }); + + renderHook(() => useMonthlyConfigSync()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(snackbarMock.error).toHaveBeenCalledWith("connection lost"); + }); + + it("writes debounced local changes back to Supabase once hydrated", async () => { + authMock.user = { id: "user-1" }; + serviceMock.fetch.mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + + const { rerender } = renderHook(() => useMonthlyConfigSync()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + globalStateMock.standardHours = 7; + globalStateMock.baseRate = 65; + rerender(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(serviceMock.upsert).toHaveBeenCalledWith("user-1", { + year: 2026, + month: 8, + standard_hours: 7, + base_rate: 65, + }); + }); + + it("does not write before hydration has resolved", async () => { + authMock.user = { id: "user-1" }; + let resolveFetch: (value: { data: null }) => void = () => {}; + serviceMock.fetch.mockReturnValue({ + call: () => new Promise((resolve) => (resolveFetch = resolve)), + }); + + renderHook(() => useMonthlyConfigSync()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(serviceMock.upsert).not.toHaveBeenCalled(); + + await act(async () => { + resolveFetch({ data: null }); + await vi.advanceTimersByTimeAsync(0); + }); + }); +}); 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; From 9ee67e35742cb7f057696e921181ee7644bfdcd4 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:20:08 +0300 Subject: [PATCH 4/9] feat(work-table): persist day status and shifts per authenticated user --- .../work-table/components/WorkTable.tsx | 2 + .../components/WorkTableDayStateHydrator.tsx | 21 ++ .../WorkTableDayStateProvider.tsx | 10 +- src/features/work-table/components/index.ts | 2 + src/features/work-table/hooks/index.ts | 2 +- .../work-table/hooks/useDayController.ts | 7 + .../hooks/useHydrateWorkTableDayState.ts | 118 ++++++++++ .../work-table/hooks/useSyncDayToStorage.ts | 86 +++++++ .../hooks/workTableDayStateContext.ts | 13 ++ src/services/index.ts | 4 + src/services/shift/shift.service.ts | 49 ++++ src/services/workDay/workDay.service.ts | 44 ++++ src/test/services/shift.service.test.ts | 85 +++++++ src/test/services/workDay.service.test.ts | 86 +++++++ .../useHydrateWorkTableDayState.test.tsx | 165 +++++++++++++ .../ui/features/useSyncDayToStorage.test.tsx | 217 ++++++++++++++++++ .../ui/hooks/useWorkTableDayState.test.tsx | 2 +- 17 files changed, 908 insertions(+), 5 deletions(-) create mode 100644 src/features/work-table/components/WorkTableDayStateHydrator.tsx rename src/features/work-table/{hooks => components}/WorkTableDayStateProvider.tsx (59%) create mode 100644 src/features/work-table/hooks/useHydrateWorkTableDayState.ts create mode 100644 src/features/work-table/hooks/useSyncDayToStorage.ts create mode 100644 src/services/shift/shift.service.ts create mode 100644 src/services/workDay/workDay.service.ts create mode 100644 src/test/services/shift.service.test.ts create mode 100644 src/test/services/workDay.service.test.ts create mode 100644 src/test/ui/features/useHydrateWorkTableDayState.test.tsx create mode 100644 src/test/ui/features/useSyncDayToStorage.test.tsx diff --git a/src/features/work-table/components/WorkTable.tsx b/src/features/work-table/components/WorkTable.tsx index 6d8f794..566068b 100644 --- a/src/features/work-table/components/WorkTable.tsx +++ b/src/features/work-table/components/WorkTable.tsx @@ -28,6 +28,7 @@ import { MonthSummaryCard, WorkTableHeader, WorkTableDayStateProvider, + WorkTableDayStateHydrator, monthToCompactPayBreakdownVM, } from "@/features/work-table"; import { DomainContextType } from "@/app"; @@ -76,6 +77,7 @@ export const WorkTable = ({ + {isMobile ? ( {workDays.map((day) => ( diff --git a/src/features/work-table/components/WorkTableDayStateHydrator.tsx b/src/features/work-table/components/WorkTableDayStateHydrator.tsx new file mode 100644 index 0000000..1f56cdf --- /dev/null +++ b/src/features/work-table/components/WorkTableDayStateHydrator.tsx @@ -0,0 +1,21 @@ +import { DomainContextType } from "@/app"; +import { WorkDayInfo } from "@/domain"; +import { useHydrateWorkTableDayState } from "@/features/work-table/hooks/useHydrateWorkTableDayState"; + +type WorkTableDayStateHydratorProps = { + domain: DomainContextType; + workDays: WorkDayInfo[]; +}; + +/** + * Renders nothing - exists only so hydration can consume + * WorkTableDayStateContext as a child of WorkTableDayStateProvider, keeping + * the provider itself free of auth/fetch dependencies. + */ +export const WorkTableDayStateHydrator = ({ + domain, + workDays, +}: WorkTableDayStateHydratorProps) => { + useHydrateWorkTableDayState({ domain, workDays }); + return null; +}; diff --git a/src/features/work-table/hooks/WorkTableDayStateProvider.tsx b/src/features/work-table/components/WorkTableDayStateProvider.tsx similarity index 59% rename from src/features/work-table/hooks/WorkTableDayStateProvider.tsx rename to src/features/work-table/components/WorkTableDayStateProvider.tsx index 9887431..9eca499 100644 --- a/src/features/work-table/hooks/WorkTableDayStateProvider.tsx +++ b/src/features/work-table/components/WorkTableDayStateProvider.tsx @@ -1,9 +1,9 @@ -import { ReactNode, useMemo, useReducer } from "react"; +import { ReactNode, useMemo, useReducer, useState } from "react"; import { WorkTableDayStateContext, workTableDayStateReducer, -} from "./workTableDayStateContext"; +} from "@/features/work-table/hooks/workTableDayStateContext"; type WorkTableDayStateProviderProps = { children: ReactNode; @@ -13,7 +13,11 @@ export const WorkTableDayStateProvider = ({ children, }: WorkTableDayStateProviderProps) => { const [state, dispatch] = useReducer(workTableDayStateReducer, {}); - const value = useMemo(() => ({ state, dispatch }), [state]); + const [hydrated, setHydrated] = useState(false); + const value = useMemo( + () => ({ state, dispatch, hydrated, setHydrated }), + [state, hydrated], + ); return ( diff --git a/src/features/work-table/components/index.ts b/src/features/work-table/components/index.ts index 51d867a..586687c 100644 --- a/src/features/work-table/components/index.ts +++ b/src/features/work-table/components/index.ts @@ -8,3 +8,5 @@ export { ShiftTimeReadonly } from "./ShiftTimeReadonly"; export { CompactDayRow } from "./rows/CompactDayRow"; export { DayCard } from "./cards/DayCard"; export { MonthSummaryCard } from "./cards/MonthSummaryCard"; +export { WorkTableDayStateProvider } from "./WorkTableDayStateProvider"; +export { WorkTableDayStateHydrator } from "./WorkTableDayStateHydrator"; diff --git a/src/features/work-table/hooks/index.ts b/src/features/work-table/hooks/index.ts index f185fd0..f80b343 100644 --- a/src/features/work-table/hooks/index.ts +++ b/src/features/work-table/hooks/index.ts @@ -3,7 +3,7 @@ export { useShift } from "./useShift"; export { useDayController } from "./useDayController"; export { useShiftEditor } from "./useShiftEditor"; export { useSyncDayToGlobalState } from "./useSyncDayToGlobalState"; +export { useSyncDayToStorage } from "./useSyncDayToStorage"; export { useWorkTableDayState, } from "./useWorkTableDayState"; -export { WorkTableDayStateProvider } from "./WorkTableDayStateProvider"; diff --git a/src/features/work-table/hooks/useDayController.ts b/src/features/work-table/hooks/useDayController.ts index 43dab0d..f3a877c 100644 --- a/src/features/work-table/hooks/useDayController.ts +++ b/src/features/work-table/hooks/useDayController.ts @@ -8,6 +8,7 @@ import { dayToPayBreakdownVM } from "@/adapters"; import { analyticsService } from "@/services/analytics"; import { useDay } from "./useDay"; import { useSyncDayToGlobalState } from "./useSyncDayToGlobalState"; +import { useSyncDayToStorage } from "./useSyncDayToStorage"; import { dayToCompactPayBreakdownVM } from "../mappers/dayToCompactPayBreakdownVM"; type UseDayControllerProps = { @@ -49,6 +50,12 @@ export const useDayController = ({ removeDay, }); + useSyncDayToStorage({ + dateKey: workDay.meta.date, + status, + shiftEntries, + }); + const specialFullDay = isSpecialFullDay(workDay.meta.date); const isEditable = status === WorkDayStatus.normal; diff --git a/src/features/work-table/hooks/useHydrateWorkTableDayState.ts b/src/features/work-table/hooks/useHydrateWorkTableDayState.ts new file mode 100644 index 0000000..d46a5c1 --- /dev/null +++ b/src/features/work-table/hooks/useHydrateWorkTableDayState.ts @@ -0,0 +1,118 @@ +import { useContext, useEffect, useRef } from "react"; + +import { DomainContextType } from "@/app"; +import { WorkDayStatus } from "@/constants"; +import { Shift, WorkDayInfo } from "@/domain"; +import { useAppSnackbar } from "@/hooks/useAppSnackbar"; +import { useAuth } from "@/hooks/useAuth"; +import { useFetch } from "@/hooks/useFetch"; +import { useGlobalState } from "@/hooks/useGlobalState"; +import { shiftService, workDayService } from "@/services"; +import { WorkTableDayState, WorkTableDayStateContext } from "./workTableDayStateContext"; + +type UseHydrateWorkTableDayStateProps = { + domain: DomainContextType; + workDays: WorkDayInfo[]; +}; + +/** + * Fetches this month's persisted day statuses and shifts for the + * authenticated user and replaces the provider's state with them. A no-op in + * guest mode, leaving the provider's empty initial state untouched. + */ +export const useHydrateWorkTableDayState = ({ + domain, + workDays, +}: UseHydrateWorkTableDayStateProps) => { + const context = useContext(WorkTableDayStateContext); + + if (!context) { + throw new Error( + "useHydrateWorkTableDayState must be used within WorkTableDayStateProvider", + ); + } + + const { dispatch, setHydrated } = context; + const { user } = useAuth(); + const { year, month, standardHours } = useGlobalState(); + const { callEndPoint } = useFetch(); + const snackbar = useAppSnackbar(); + + // standardHours is only needed to compute a hydrated shift's payMap, not to + // decide whether to re-fetch - it's read via a ref so a config change + // (e.g. useMonthlyConfigSync hydrating it moments after mount) can't + // re-trigger this effect and clobber local edits made in between with a + // stale full-state "hydrate" dispatch. + const standardHoursRef = useRef(standardHours); + useEffect(() => { + standardHoursRef.current = standardHours; + }); + + useEffect(() => { + if (!user || workDays.length === 0) return; + + let cancelled = false; + const { startDate, endDate } = domain.services.dateService.getDatesRange(year, month); + + void Promise.all([ + callEndPoint(workDayService().fetchForMonth(user.id, startDate, endDate)), + callEndPoint(shiftService().fetchForMonth(user.id, startDate, endDate)), + ]).then(([daysResult, shiftsResult]) => { + if (cancelled) return; + + if (daysResult.error) { + snackbar.error(daysResult.error); + return; + } + if (shiftsResult.error) { + snackbar.error(shiftsResult.error); + return; + } + + const hydratedState: WorkTableDayState = {}; + + for (const day of daysResult.data ?? []) { + hydratedState[day.date] = { status: day.status, shiftEntries: {} }; + } + + for (const row of shiftsResult.data ?? []) { + const meta = workDays.find((day) => day.meta.date === row.date)?.meta; + if (!meta) continue; + + const shift: Shift = { + id: row.id, + start: { date: new Date(row.start_time) }, + end: { date: new Date(row.end_time) }, + isDuty: row.is_duty, + }; + + const payMap = domain.payMap.shiftMapBuilder.build({ + shift, + meta, + standardHours: standardHoursRef.current, + isFieldDutyShift: shift.isDuty, + }); + + const dayState = hydratedState[row.date] ?? { + status: WorkDayStatus.normal, + shiftEntries: {}, + }; + + hydratedState[row.date] = { + ...dayState, + shiftEntries: { ...dayState.shiftEntries, [shift.id]: { shift, payMap } }, + }; + } + + dispatch({ type: "hydrate", state: hydratedState }); + setHydrated(true); + }); + + return () => { + cancelled = true; + }; + // snackbar isn't referentially stable (AppSnackbarProvider doesn't memoize + // its context value), so it's read via the closure instead of listed here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, year, month, workDays, domain, callEndPoint, dispatch, setHydrated]); +}; diff --git a/src/features/work-table/hooks/useSyncDayToStorage.ts b/src/features/work-table/hooks/useSyncDayToStorage.ts new file mode 100644 index 0000000..9225a6a --- /dev/null +++ b/src/features/work-table/hooks/useSyncDayToStorage.ts @@ -0,0 +1,86 @@ +import { useContext, useEffect, useRef } from "react"; + +import { WorkDayStatus } from "@/constants"; +import { useAppSnackbar } from "@/hooks/useAppSnackbar"; +import { useAuth } from "@/hooks/useAuth"; +import { useFetch } from "@/hooks/useFetch"; +import { shiftService, workDayService } from "@/services"; +import { ShiftEntries, ShiftEntry, WorkTableDayStateContext } from "./workTableDayStateContext"; + +type UseSyncDayToStorageProps = { + dateKey: string; + status: WorkDayStatus; + shiftEntries: ShiftEntries; +}; + +/** + * Write-through persistence for a single day's status and saved shifts. + * A shift only counts as "saved" once it has a payMap (see useShiftEditor's + * handleSave) - draft edits never reach Supabase. A no-op in guest mode. + * + * Gated on the provider's `hydrated` flag: before hydration resolves, status + * and shiftEntries are just the empty/default placeholder, not a real user + * change. Writing that placeholder (status "normal" upserts as a delete) + * would race against hydration's own correction and can wipe a persisted + * sick/vacation day if the delete lands after the hydrated re-sync. + */ +export const useSyncDayToStorage = ({ + dateKey, + status, + shiftEntries, +}: UseSyncDayToStorageProps) => { + const context = useContext(WorkTableDayStateContext); + + if (!context) { + throw new Error("useSyncDayToStorage must be used within WorkTableDayStateProvider"); + } + + const { hydrated } = context; + const { user } = useAuth(); + const { callEndPoint } = useFetch(); + const snackbar = useAppSnackbar(); + + const prevStatusRef = useRef(null); + const prevSavedIdsRef = useRef>({}); + + useEffect(() => { + if (!user || !hydrated) return; + if (prevStatusRef.current === status) return; + prevStatusRef.current = status; + + void callEndPoint(workDayService().setStatus(user.id, dateKey, status)).then((result) => { + if (result.error) snackbar.error(result.error); + }); + // snackbar isn't referentially stable (AppSnackbarProvider doesn't memoize + // its context value), so it's read via the closure instead of listed here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, hydrated, dateKey, status, callEndPoint]); + + useEffect(() => { + if (!user || !hydrated) return; + + const savedEntries = Object.values(shiftEntries).filter((entry) => entry.payMap !== null); + const savedIds = Object.fromEntries(savedEntries.map((entry) => [entry.shift.id, entry])); + const prevSavedIds = prevSavedIdsRef.current; + prevSavedIdsRef.current = savedIds; + + const removedIds = Object.keys(prevSavedIds).filter((id) => !(id in savedIds)); + const changedEntries = savedEntries.filter( + (entry) => prevSavedIds[entry.shift.id]?.shift !== entry.shift, + ); + + removedIds.forEach((id) => { + void callEndPoint(shiftService().remove(user.id, id)).then((result) => { + if (result.error) snackbar.error(result.error); + }); + }); + + changedEntries.forEach((entry) => { + void callEndPoint(shiftService().upsert(user.id, dateKey, entry.shift)).then((result) => { + if (result.error) snackbar.error(result.error); + }); + }); + // snackbar isn't referentially stable (see comment above) - read via closure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, hydrated, dateKey, shiftEntries, callEndPoint]); +}; diff --git a/src/features/work-table/hooks/workTableDayStateContext.ts b/src/features/work-table/hooks/workTableDayStateContext.ts index 4b5a917..37ba10a 100644 --- a/src/features/work-table/hooks/workTableDayStateContext.ts +++ b/src/features/work-table/hooks/workTableDayStateContext.ts @@ -27,11 +27,22 @@ export type WorkTableDayStateAction = type: "setShiftEntries"; dateKey: string; value: SetStateAction; + } + | { + type: "hydrate"; + state: WorkTableDayState; }; export type WorkTableDayStateContextValue = { state: WorkTableDayState; dispatch: Dispatch; + // True once hydration has resolved for this month (or immediately stays + // false forever in guest mode, where nothing writes anyway). Consumers that + // persist to Supabase must wait for this - otherwise the pre-hydration + // default state looks like a real user change and gets written, racing + // against the hydrated value's own sync. + hydrated: boolean; + setHydrated: Dispatch>; }; export const emptyDayState: DayEditingState = { @@ -46,6 +57,8 @@ export const workTableDayStateReducer = ( state: WorkTableDayState, action: WorkTableDayStateAction, ): WorkTableDayState => { + if (action.type === "hydrate") return action.state; + const currentDayState = state[action.dateKey] ?? emptyDayState; if (action.type === "setStatus") { diff --git a/src/services/index.ts b/src/services/index.ts index 709dff5..da70597 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -3,3 +3,7 @@ export { analyticsService, gtagService } from "./analytics"; export type { SalaryFeedback } from "./analytics"; export { monthlyConfigService } from "./monthlyConfig/monthlyConfig.service"; export type { MonthlyConfigRecord } from "./monthlyConfig/monthlyConfig.service"; +export { workDayService } from "./workDay/workDay.service"; +export type { WorkDayRecord } from "./workDay/workDay.service"; +export { shiftService } from "./shift/shift.service"; +export type { ShiftRecord } from "./shift/shift.service"; diff --git a/src/services/shift/shift.service.ts b/src/services/shift/shift.service.ts new file mode 100644 index 0000000..9a7af1a --- /dev/null +++ b/src/services/shift/shift.service.ts @@ -0,0 +1,49 @@ +import { Shift } from "@/domain"; +import { supabase } from "@/services/supabase/supabase.client"; +import { fromSupabaseResult } from "@/utils"; + +export interface ShiftRecord { + id: string; + date: string; + start_time: string; + end_time: string; + is_duty: boolean; +} + +export const shiftService = () => { + const fetchForMonth = (userId: string, startDate: string, endDate: string) => ({ + call: async () => + fromSupabaseResult( + await supabase + .from("shifts") + .select("id, date, start_time, end_time, is_duty") + .eq("user_id", userId) + .gte("date", startDate) + .lt("date", endDate), + ), + }); + + const upsert = (userId: string, date: string, shift: Shift) => ({ + call: async () => + fromSupabaseResult( + await supabase.from("shifts").upsert({ + id: shift.id, + user_id: userId, + date, + start_time: shift.start.date.toISOString(), + end_time: shift.end.date.toISOString(), + is_duty: shift.isDuty, + updated_at: new Date().toISOString(), + }), + ), + }); + + const remove = (userId: string, shiftId: string) => ({ + call: async () => + fromSupabaseResult( + await supabase.from("shifts").delete().eq("user_id", userId).eq("id", shiftId), + ), + }); + + return { fetchForMonth, upsert, remove }; +}; diff --git a/src/services/workDay/workDay.service.ts b/src/services/workDay/workDay.service.ts new file mode 100644 index 0000000..f616007 --- /dev/null +++ b/src/services/workDay/workDay.service.ts @@ -0,0 +1,44 @@ +import { WorkDayStatus } from "@/constants"; +import { supabase } from "@/services/supabase/supabase.client"; +import { fromSupabaseResult } from "@/utils"; + +export interface WorkDayRecord { + date: string; + status: WorkDayStatus; +} + +export const workDayService = () => { + const fetchForMonth = (userId: string, startDate: string, endDate: string) => ({ + call: async () => + fromSupabaseResult( + await supabase + .from("work_days") + .select("date, status") + .eq("user_id", userId) + .gte("date", startDate) + .lt("date", endDate), + ), + }); + + // "normal" is the implicit default (row absence = normal), so it's never + // written - only non-normal statuses are stored, and reverting to normal + // deletes the row instead of leaving stale data behind. + const setStatus = (userId: string, date: string, status: WorkDayStatus) => ({ + call: async () => { + if (status === WorkDayStatus.normal) { + return fromSupabaseResult( + await supabase.from("work_days").delete().eq("user_id", userId).eq("date", date), + ); + } + + return fromSupabaseResult( + await supabase.from("work_days").upsert( + { user_id: userId, date, status, updated_at: new Date().toISOString() }, + { onConflict: "user_id,date" }, + ), + ); + }, + }); + + return { fetchForMonth, setStatus }; +}; diff --git a/src/test/services/shift.service.test.ts b/src/test/services/shift.service.test.ts new file mode 100644 index 0000000..1c80f4a --- /dev/null +++ b/src/test/services/shift.service.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fromMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/supabase/supabase.client", () => ({ + supabase: { from: fromMock }, +})); + +import { shiftService } from "@/services/shift/shift.service"; + +describe("shiftService", () => { + beforeEach(() => { + fromMock.mockReset(); + }); + + it("fetches persisted shifts for a date range", async () => { + const lt = vi.fn().mockResolvedValue({ + data: [ + { + id: "shift-1", + date: "2026-08-10", + start_time: "2026-08-10T08:00:00.000Z", + end_time: "2026-08-10T16:00:00.000Z", + is_duty: false, + }, + ], + error: null, + }); + const gte = vi.fn().mockReturnValue({ lt }); + const eq = vi.fn().mockReturnValue({ gte }); + const select = vi.fn().mockReturnValue({ eq }); + fromMock.mockReturnValue({ select }); + + const result = await shiftService() + .fetchForMonth("user-1", "2026-08-01", "2026-09-01") + .call(); + + expect(fromMock).toHaveBeenCalledWith("shifts"); + expect(select).toHaveBeenCalledWith("id, date, start_time, end_time, is_duty"); + expect(eq).toHaveBeenCalledWith("user_id", "user-1"); + expect(gte).toHaveBeenCalledWith("date", "2026-08-01"); + expect(lt).toHaveBeenCalledWith("date", "2026-09-01"); + expect(result.data).toHaveLength(1); + }); + + it("upserts a shift keyed by its client-generated id", async () => { + const upsert = vi.fn().mockResolvedValue({ data: null, error: null }); + fromMock.mockReturnValue({ upsert }); + + const shift = { + id: "shift-1", + start: { date: new Date("2026-08-10T08:00:00.000Z") }, + end: { date: new Date("2026-08-10T16:00:00.000Z") }, + isDuty: true, + }; + + const result = await shiftService().upsert("user-1", "2026-08-10", shift).call(); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: "shift-1", + user_id: "user-1", + date: "2026-08-10", + start_time: shift.start.date.toISOString(), + end_time: shift.end.date.toISOString(), + is_duty: true, + }), + ); + expect(result).toEqual({ data: null }); + }); + + it("removes a shift scoped to the user", async () => { + const eqId = vi.fn().mockResolvedValue({ data: null, error: null }); + const eqUser = vi.fn().mockReturnValue({ eq: eqId }); + const del = vi.fn().mockReturnValue({ eq: eqUser }); + fromMock.mockReturnValue({ delete: del }); + + const result = await shiftService().remove("user-1", "shift-1").call(); + + expect(fromMock).toHaveBeenCalledWith("shifts"); + expect(eqUser).toHaveBeenCalledWith("user_id", "user-1"); + expect(eqId).toHaveBeenCalledWith("id", "shift-1"); + expect(result).toEqual({ data: null }); + }); +}); diff --git a/src/test/services/workDay.service.test.ts b/src/test/services/workDay.service.test.ts new file mode 100644 index 0000000..9b2fc06 --- /dev/null +++ b/src/test/services/workDay.service.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fromMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/supabase/supabase.client", () => ({ + supabase: { from: fromMock }, +})); + +import { WorkDayStatus } from "@/constants"; +import { workDayService } from "@/services/workDay/workDay.service"; + +describe("workDayService", () => { + beforeEach(() => { + fromMock.mockReset(); + }); + + it("fetches persisted day statuses for a date range", async () => { + const lt = vi.fn().mockResolvedValue({ + data: [{ date: "2026-08-10", status: "sick" }], + error: null, + }); + const gte = vi.fn().mockReturnValue({ lt }); + const eq = vi.fn().mockReturnValue({ gte }); + const select = vi.fn().mockReturnValue({ eq }); + fromMock.mockReturnValue({ select }); + + const result = await workDayService() + .fetchForMonth("user-1", "2026-08-01", "2026-09-01") + .call(); + + expect(fromMock).toHaveBeenCalledWith("work_days"); + expect(select).toHaveBeenCalledWith("date, status"); + expect(eq).toHaveBeenCalledWith("user_id", "user-1"); + expect(gte).toHaveBeenCalledWith("date", "2026-08-01"); + expect(lt).toHaveBeenCalledWith("date", "2026-09-01"); + expect(result).toEqual({ data: [{ date: "2026-08-10", status: "sick" }] }); + }); + + it("deletes the row when reverting a day to normal", async () => { + const eqDate = vi.fn().mockResolvedValue({ data: null, error: null }); + const eqUser = vi.fn().mockReturnValue({ eq: eqDate }); + const del = vi.fn().mockReturnValue({ eq: eqUser }); + fromMock.mockReturnValue({ delete: del }); + + const result = await workDayService() + .setStatus("user-1", "2026-08-10", WorkDayStatus.normal) + .call(); + + expect(fromMock).toHaveBeenCalledWith("work_days"); + expect(eqUser).toHaveBeenCalledWith("user_id", "user-1"); + expect(eqDate).toHaveBeenCalledWith("date", "2026-08-10"); + expect(result).toEqual({ data: null }); + }); + + it("upserts the status when marking a day sick or vacation", async () => { + const upsert = vi.fn().mockResolvedValue({ data: null, error: null }); + fromMock.mockReturnValue({ upsert }); + + const result = await workDayService() + .setStatus("user-1", "2026-08-10", WorkDayStatus.sick) + .call(); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: "user-1", + date: "2026-08-10", + status: WorkDayStatus.sick, + }), + { onConflict: "user_id,date" }, + ); + expect(result).toEqual({ data: null }); + }); + + it("returns an error when the fetch fails", async () => { + const lt = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); + fromMock.mockReturnValue({ + select: () => ({ eq: () => ({ gte: () => ({ lt }) }) }), + }); + + const result = await workDayService() + .fetchForMonth("user-1", "2026-08-01", "2026-09-01") + .call(); + + expect(result).toEqual({ error: "boom" }); + }); +}); diff --git a/src/test/ui/features/useHydrateWorkTableDayState.test.tsx b/src/test/ui/features/useHydrateWorkTableDayState.test.tsx new file mode 100644 index 0000000..32cba90 --- /dev/null +++ b/src/test/ui/features/useHydrateWorkTableDayState.test.tsx @@ -0,0 +1,165 @@ +import { act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMock = vi.hoisted(() => ({ user: null as { id: string } | null })); + +const globalStateMock = vi.hoisted(() => ({ + year: 2026, + month: 8, + standardHours: 6.67, +})); + +const snackbarMock = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +const workDayServiceMock = vi.hoisted(() => ({ fetchForMonth: vi.fn() })); +const shiftServiceMock = vi.hoisted(() => ({ fetchForMonth: vi.fn() })); + +vi.mock("@/hooks/useAuth", () => ({ useAuth: () => authMock })); +vi.mock("@/hooks/useGlobalState", () => ({ useGlobalState: () => globalStateMock })); +vi.mock("@/hooks/useAppSnackbar", () => ({ useAppSnackbar: () => snackbarMock })); +vi.mock("@/services", () => ({ + workDayService: () => workDayServiceMock, + shiftService: () => shiftServiceMock, +})); + +import type { DomainContextType } from "@/app"; +import { WorkDayStatus, WorkDayType } from "@/constants"; +import type { WorkDayInfo } from "@/domain"; +import { WorkTableDayStateHydrator } from "@/features/work-table/components/WorkTableDayStateHydrator"; +import { WorkTableDayStateProvider } from "@/features/work-table/components/WorkTableDayStateProvider"; +import { useWorkTableDayState } from "@/features/work-table/hooks/useWorkTableDayState"; +import { renderPure, screen } from "@/test/ui/utils"; + +const domainStub = { + services: { + dateService: { + getDatesRange: () => ({ startDate: "2026-08-01", endDate: "2026-09-01" }), + }, + }, + payMap: { + shiftMapBuilder: { + build: vi.fn().mockReturnValue({ totalHours: 8 }), + }, + }, +} as unknown as DomainContextType; + +const workDays: WorkDayInfo[] = [ + { + meta: { date: "2026-08-10", typeDay: WorkDayType.Regular, crossDayContinuation: false }, + hebrewDay: "", + }, +]; + +const DayProbe = ({ dateKey }: { dateKey: string }) => { + const { status, shiftEntries } = useWorkTableDayState(dateKey); + return {`${dateKey}:${status}:${Object.keys(shiftEntries).length}`}; +}; + +describe("useHydrateWorkTableDayState", () => { + beforeEach(() => { + authMock.user = null; + globalStateMock.standardHours = 6.67; + workDayServiceMock.fetchForMonth.mockReset(); + shiftServiceMock.fetchForMonth.mockReset(); + }); + + it("does nothing in guest mode", async () => { + renderPure( + + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText("2026-08-10:normal:0")).toBeInTheDocument(); + expect(workDayServiceMock.fetchForMonth).not.toHaveBeenCalled(); + }); + + it("hydrates persisted status and shifts for an authenticated user", async () => { + authMock.user = { id: "user-1" }; + workDayServiceMock.fetchForMonth.mockReturnValue({ + call: () => + Promise.resolve({ data: [{ date: "2026-08-10", status: WorkDayStatus.sick }] }), + }); + shiftServiceMock.fetchForMonth.mockReturnValue({ + call: () => + Promise.resolve({ + data: [ + { + id: "shift-1", + date: "2026-08-10", + start_time: "2026-08-10T08:00:00.000Z", + end_time: "2026-08-10T16:00:00.000Z", + is_duty: false, + }, + ], + }), + }); + + renderPure( + + + + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(workDayServiceMock.fetchForMonth).toHaveBeenCalledWith( + "user-1", + "2026-08-01", + "2026-09-01", + ); + expect(screen.getByText("2026-08-10:sick:1")).toBeInTheDocument(); + }); + + it("does not re-fetch and clobber local edits when standardHours changes after mount", async () => { + // Regression test: useMonthlyConfigSync hydrates standardHours shortly + // after mount. That must not re-trigger this hydration and overwrite a + // status the user just set locally with a stale full-state dispatch. + authMock.user = { id: "user-1" }; + workDayServiceMock.fetchForMonth.mockReturnValue({ + call: () => Promise.resolve({ data: [] }), + }); + shiftServiceMock.fetchForMonth.mockReturnValue({ + call: () => Promise.resolve({ data: [] }), + }); + + const Harness = () => ( + + + + + ); + + const { rerender } = renderPure(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(workDayServiceMock.fetchForMonth).toHaveBeenCalledTimes(1); + + globalStateMock.standardHours = 7.5; + rerender(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(workDayServiceMock.fetchForMonth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/ui/features/useSyncDayToStorage.test.tsx b/src/test/ui/features/useSyncDayToStorage.test.tsx new file mode 100644 index 0000000..4fd8f9a --- /dev/null +++ b/src/test/ui/features/useSyncDayToStorage.test.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from "react"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { WorkDayStatus } from "@/constants"; +import { + WorkTableDayStateContext, + type ShiftEntries, +} from "@/features/work-table/hooks/workTableDayStateContext"; + +const authMock = vi.hoisted(() => ({ user: null as { id: string } | null })); + +const snackbarMock = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +const workDayServiceMock = vi.hoisted(() => ({ + setStatus: vi.fn(), + fetchForMonth: vi.fn(), +})); + +const shiftServiceMock = vi.hoisted(() => ({ + upsert: vi.fn(), + remove: vi.fn(), + fetchForMonth: vi.fn(), +})); + +vi.mock("@/hooks/useAuth", () => ({ useAuth: () => authMock })); +vi.mock("@/hooks/useAppSnackbar", () => ({ useAppSnackbar: () => snackbarMock })); +vi.mock("@/services", () => ({ + workDayService: () => workDayServiceMock, + shiftService: () => shiftServiceMock, +})); + +import { useSyncDayToStorage } from "@/features/work-table/hooks/useSyncDayToStorage"; + +const buildShiftEntry = (id: string, saved: boolean) => ({ + shift: { + id, + start: { date: new Date("2026-08-10T08:00:00") }, + end: { date: new Date("2026-08-10T16:00:00") }, + isDuty: false, + }, + payMap: saved ? ({ totalHours: 8 } as never) : null, +}); + +const hydratedWrapper = + (hydrated: boolean) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + +describe("useSyncDayToStorage", () => { + beforeEach(() => { + authMock.user = null; + snackbarMock.error.mockReset(); + workDayServiceMock.setStatus + .mockReset() + .mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + shiftServiceMock.upsert + .mockReset() + .mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + shiftServiceMock.remove + .mockReset() + .mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + }); + + it("does nothing in guest mode", async () => { + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(true), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: {} as ShiftEntries, + }, + }); + + await act(async () => { + rerender({ dateKey: "2026-08-10", status: WorkDayStatus.sick, shiftEntries: {} }); + await Promise.resolve(); + }); + + expect(workDayServiceMock.setStatus).not.toHaveBeenCalled(); + }); + + it("does not persist anything before hydration has resolved", async () => { + // Regression test: status/shiftEntries are just the pre-hydration + // placeholder here, not a real user change - writing it would race + // against hydration's own sync and can wipe a persisted day. + authMock.user = { id: "user-1" }; + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(false), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: {} as ShiftEntries, + }, + }); + + await act(async () => { + rerender({ + dateKey: "2026-08-10", + status: WorkDayStatus.sick, + shiftEntries: { "shift-1": buildShiftEntry("shift-1", true) }, + }); + await Promise.resolve(); + }); + + expect(workDayServiceMock.setStatus).not.toHaveBeenCalled(); + expect(shiftServiceMock.upsert).not.toHaveBeenCalled(); + }); + + it("persists a status change once hydrated", async () => { + authMock.user = { id: "user-1" }; + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(true), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: {} as ShiftEntries, + }, + }); + + await act(async () => { + rerender({ dateKey: "2026-08-10", status: WorkDayStatus.sick, shiftEntries: {} }); + await Promise.resolve(); + }); + + expect(workDayServiceMock.setStatus).toHaveBeenCalledWith( + "user-1", + "2026-08-10", + WorkDayStatus.sick, + ); + }); + + it("upserts a shift once it has been saved", async () => { + authMock.user = { id: "user-1" }; + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(true), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: {} as ShiftEntries, + }, + }); + + const savedEntry = buildShiftEntry("shift-1", true); + await act(async () => { + rerender({ + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: { "shift-1": savedEntry }, + }); + await Promise.resolve(); + }); + + expect(shiftServiceMock.upsert).toHaveBeenCalledWith( + "user-1", + "2026-08-10", + savedEntry.shift, + ); + }); + + it("does not persist a draft shift that hasn't been saved yet", async () => { + authMock.user = { id: "user-1" }; + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(true), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: {} as ShiftEntries, + }, + }); + + await act(async () => { + rerender({ + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: { "shift-1": buildShiftEntry("shift-1", false) }, + }); + await Promise.resolve(); + }); + + expect(shiftServiceMock.upsert).not.toHaveBeenCalled(); + }); + + it("removes a shift that was saved and then deleted", async () => { + authMock.user = { id: "user-1" }; + const savedEntry = buildShiftEntry("shift-1", true); + const { rerender } = renderHook((props) => useSyncDayToStorage(props), { + wrapper: hydratedWrapper(true), + initialProps: { + dateKey: "2026-08-10", + status: WorkDayStatus.normal, + shiftEntries: { "shift-1": savedEntry } as ShiftEntries, + }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + rerender({ dateKey: "2026-08-10", status: WorkDayStatus.normal, shiftEntries: {} }); + await Promise.resolve(); + }); + + expect(shiftServiceMock.remove).toHaveBeenCalledWith("user-1", "shift-1"); + }); +}); diff --git a/src/test/ui/hooks/useWorkTableDayState.test.tsx b/src/test/ui/hooks/useWorkTableDayState.test.tsx index 9785387..5025929 100644 --- a/src/test/ui/hooks/useWorkTableDayState.test.tsx +++ b/src/test/ui/hooks/useWorkTableDayState.test.tsx @@ -5,7 +5,7 @@ import { WorkDayStatus } from "@/constants"; import { useWorkTableDayState, } from "@/features/work-table/hooks/useWorkTableDayState"; -import { WorkTableDayStateProvider } from "@/features/work-table/hooks/WorkTableDayStateProvider"; +import { WorkTableDayStateProvider } from "@/features/work-table/components/WorkTableDayStateProvider"; import { renderPure, screen, userEvent } from "@/test/ui/utils"; const dateKey = "2026-08-26"; From 4fb03ae58b13adf43fff8c61cfed67aab50c1800 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:20:38 +0300 Subject: [PATCH 5/9] feat(domain): carry unused Shabbat credit hours to the next month --- .../calculator/shabbat-credit.calculator.ts | 11 ++++++++-- src/domain/services/date.service.ts | 4 ++++ .../shabbat-credit.calculator.test.ts | 20 +++++++++++++++++++ src/test/domain/services/date.service.test.ts | 10 ++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/domain/calculator/shabbat-credit.calculator.ts b/src/domain/calculator/shabbat-credit.calculator.ts index 50251ce..6ebbe1c 100644 --- a/src/domain/calculator/shabbat-credit.calculator.ts +++ b/src/domain/calculator/shabbat-credit.calculator.ts @@ -8,7 +8,9 @@ type ShabbatCreditDayPayMap = Pick< >; export type ShabbatCreditAllocation = { + carriedOverHours: number; earnedHours: number; + totalAvailableHours: number; usedHours: number; unusedHours: number; appliedHoursByDate: Record; @@ -18,13 +20,16 @@ export const allocateShabbatCredit = (params: { workDays: ReadonlyArray>; dailyPayMaps: Readonly>; standardHours: number; + carriedOverHours?: number; }): ShabbatCreditAllocation => { + const carriedOverHours = params.carriedOverHours ?? 0; const earnedHours = Object.values(params.dailyPayMaps).reduce( (total, day) => total + day.earnedShabbatCredit.hours, 0, ); + const totalAvailableHours = carriedOverHours + earnedHours; - let remainingHours = earnedHours; + let remainingHours = totalAvailableHours; const appliedHoursByDate: Record = {}; const chronologicalDays = [...params.workDays].sort((a, b) => @@ -51,8 +56,10 @@ export const allocateShabbatCredit = (params: { } return { + carriedOverHours, earnedHours, - usedHours: earnedHours - remainingHours, + totalAvailableHours, + usedHours: totalAvailableHours - remainingHours, unusedHours: remainingHours, appliedHoursByDate, }; diff --git a/src/domain/services/date.service.ts b/src/domain/services/date.service.ts index bae4f0a..1def0c9 100644 --- a/src/domain/services/date.service.ts +++ b/src/domain/services/date.service.ts @@ -66,6 +66,10 @@ export class DateService { return new Date(nextYear, nextMonth - 1, 1); } + getPreviousMonth(year: number, month: number): { year: number; month: number } { + return month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 }; + } + getDaysInMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } diff --git a/src/test/domain/calculator/shabbat-credit.calculator.test.ts b/src/test/domain/calculator/shabbat-credit.calculator.test.ts index e2bb91d..015f082 100644 --- a/src/test/domain/calculator/shabbat-credit.calculator.test.ts +++ b/src/test/domain/calculator/shabbat-credit.calculator.test.ts @@ -35,7 +35,9 @@ describe("allocateShabbatCredit", () => { }); expect(result).toEqual({ + carriedOverHours: 0, earnedHours: 10, + totalAvailableHours: 10, usedHours: 10, unusedHours: 0, appliedHoursByDate: { @@ -45,6 +47,24 @@ describe("allocateShabbatCredit", () => { }); }); + it("adds carried-over hours from a previous month to the available pool", () => { + const result = allocateShabbatCredit({ + workDays: [createWorkDay("2026-08-02")], + dailyPayMaps: { + "2026-08-02": createPayMap(5), + }, + standardHours: 8, + carriedOverHours: 3.5, + }); + + expect(result.carriedOverHours).toBe(3.5); + expect(result.earnedHours).toBe(0); + expect(result.totalAvailableHours).toBe(3.5); + expect(result.usedHours).toBe(3); + expect(result.unusedHours).toBe(0.5); + expect(result.appliedHoursByDate).toEqual({ "2026-08-02": 3 }); + }); + it("does not apply credit to holidays or days already at standard hours", () => { const result = allocateShabbatCredit({ workDays: [ diff --git a/src/test/domain/services/date.service.test.ts b/src/test/domain/services/date.service.test.ts index c5ac565..9d25cc0 100644 --- a/src/test/domain/services/date.service.test.ts +++ b/src/test/domain/services/date.service.test.ts @@ -413,6 +413,16 @@ describe("DateService", () => { }); }); + describe("getPreviousMonth", () => { + it("returns the prior month within the same year", () => { + expect(service.getPreviousMonth(2025, 6)).toEqual({ year: 2025, month: 5 }); + }); + + it("handles the year boundary (January -> December of the prior year)", () => { + expect(service.getPreviousMonth(2025, 1)).toEqual({ year: 2024, month: 12 }); + }); + }); + describe("getDaysInMonth", () => { it("should return 31 for January", () => { const result = service.getDaysInMonth(2025, 1); From 8376667dd4990acb13ef882ed146099a1c62ad22 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:21:22 +0300 Subject: [PATCH 6/9] feat(salary-summary): fetch/persist Shabbat-credit carry-over and show it in the UI --- .../components/MonthlySalarySummary.tsx | 3 +- src/features/salary-summary/hooks/index.ts | 1 + .../hooks/useShabbatCreditAllocation.ts | 102 +++++++++++ .../work-table/components/WorkTable.tsx | 9 +- src/hooks/useShabbatCreditAllocation.ts | 21 --- src/i18n/locales/en/work-table.json | 3 +- src/i18n/locales/he/work-table.json | 3 +- .../components/MonthlySalarySummary.test.tsx | 2 +- .../useShabbatCreditAllocation.test.tsx | 166 ++++++++++++++++++ 9 files changed, 284 insertions(+), 26 deletions(-) create mode 100644 src/features/salary-summary/hooks/useShabbatCreditAllocation.ts delete mode 100644 src/hooks/useShabbatCreditAllocation.ts create mode 100644 src/test/ui/features/useShabbatCreditAllocation.test.tsx diff --git a/src/features/salary-summary/components/MonthlySalarySummary.tsx b/src/features/salary-summary/components/MonthlySalarySummary.tsx index 4ec1775..0354044 100644 --- a/src/features/salary-summary/components/MonthlySalarySummary.tsx +++ b/src/features/salary-summary/components/MonthlySalarySummary.tsx @@ -9,8 +9,9 @@ import { SummaryHeader, SalaryCardSection, useMonthlySalarySummary, + useShabbatCreditAllocation, } from "@/features/salary-summary"; -import { useGlobalState, useShabbatCreditAllocation } from "@/hooks"; +import { useGlobalState } from "@/hooks"; export const MonthlySalarySummary = ({ domain }: { domain: DomainContextType; diff --git a/src/features/salary-summary/hooks/index.ts b/src/features/salary-summary/hooks/index.ts index 4827b7f..86bde96 100644 --- a/src/features/salary-summary/hooks/index.ts +++ b/src/features/salary-summary/hooks/index.ts @@ -2,3 +2,4 @@ export { useEditableQuantity } from "./useEditableQuantity"; export { useMonthlySalarySummary } from "./useMonthlySalarySummary"; export { usePayTableVM } from "./usePayTableVM"; export { useSalaryRow } from "./useSalaryRow"; +export { useShabbatCreditAllocation } from "./useShabbatCreditAllocation"; diff --git a/src/features/salary-summary/hooks/useShabbatCreditAllocation.ts b/src/features/salary-summary/hooks/useShabbatCreditAllocation.ts new file mode 100644 index 0000000..d56fff3 --- /dev/null +++ b/src/features/salary-summary/hooks/useShabbatCreditAllocation.ts @@ -0,0 +1,102 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSelector } from "react-redux"; + +import { allocateShabbatCredit } from "@/domain"; +import { RootState } from "@/redux/store"; +import { useAppSnackbar } from "@/hooks/useAppSnackbar"; +import { useAuth } from "@/hooks/useAuth"; +import { useDomain } from "@/hooks/useDomain"; +import { useFetch } from "@/hooks/useFetch"; +import { useGlobalState } from "@/hooks/useGlobalState"; +import { monthlyConfigService } from "@/services"; + +export const useShabbatCreditAllocation = () => { + const { user } = useAuth(); + const { year, month } = useGlobalState(); + const { services } = useDomain(); + const { callEndPoint } = useFetch(); + const snackbar = useAppSnackbar(); + + const workDays = useSelector((state: RootState) => state.workDays.workDays); + const dailyPayMaps = useSelector( + (state: RootState) => state.global.dailyPayMaps, + ); + const standardHours = useSelector( + (state: RootState) => state.global.config.standardHours, + ); + + const [carriedOverHours, setCarriedOverHours] = useState(0); + // Tracks which (user, year, month) the carry-over fetch has resolved for, + // as STATE rather than a ref: the write-back effect below needs a + // guaranteed re-render on resolution to re-check its guard, and + // carriedOverHours alone doesn't provide that when the fetched value + // happens to equal its previous value (e.g. 0 -> 0, the common case of no + // prior unused credit) - React skips the re-render for an unchanged state + // value, so a distinct key string is what reliably triggers it here. + const [resolvedKey, setResolvedKey] = useState(null); + const prevUnusedRef = useRef(null); + + useEffect(() => { + if (!user) return; + + let cancelled = false; + const previous = services.dateService.getPreviousMonth(year, month); + + void callEndPoint( + monthlyConfigService().fetch(user.id, previous.year, previous.month), + ).then((result) => { + if (cancelled) return; + + if (result.error) { + snackbar.error(result.error); + setCarriedOverHours(0); + } else { + setCarriedOverHours(result.data?.unused_shabbat_credit_hours ?? 0); + } + // Reset so a coincidental match against a DIFFERENT month's last + // written unusedHours doesn't wrongly skip this month's write. + prevUnusedRef.current = null; + setResolvedKey(`${user.id}:${year}:${month}`); + }); + + return () => { + cancelled = true; + }; + // snackbar isn't referentially stable (AppSnackbarProvider doesn't memoize + // its context value), so it's read via the closure instead of listed here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, year, month, services, callEndPoint]); + + const allocation = useMemo( + () => + allocateShabbatCredit({ + workDays, + dailyPayMaps, + standardHours, + carriedOverHours: user ? carriedOverHours : 0, + }), + [workDays, dailyPayMaps, standardHours, carriedOverHours, user], + ); + + useEffect(() => { + if (!user) return; + if (resolvedKey !== `${user.id}:${year}:${month}`) return; + if (prevUnusedRef.current === allocation.unusedHours) return; + prevUnusedRef.current = allocation.unusedHours; + + void callEndPoint( + monthlyConfigService().setUnusedShabbatCreditHours( + user.id, + year, + month, + allocation.unusedHours, + ), + ).then((result) => { + if (result.error) snackbar.error(result.error); + }); + // snackbar isn't referentially stable (see comment above) - read via closure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, year, month, resolvedKey, allocation.unusedHours, callEndPoint]); + + return allocation; +}; diff --git a/src/features/work-table/components/WorkTable.tsx b/src/features/work-table/components/WorkTable.tsx index 566068b..1984063 100644 --- a/src/features/work-table/components/WorkTable.tsx +++ b/src/features/work-table/components/WorkTable.tsx @@ -177,13 +177,20 @@ export const WorkTable = ({ )} - {shabbatCreditAllocation.earnedHours > 0 && ( + {shabbatCreditAllocation.totalAvailableHours > 0 && ( 0 ? "warning" : "info" } sx={{ mt: 2 }} > + {shabbatCreditAllocation.carriedOverHours > 0 && ( + + {t("table.shabbat_credit_carried_over", { + hours: shabbatCreditAllocation.carriedOverHours.toFixed(2), + })} + + )} {t("table.shabbat_credit_summary", { earned: shabbatCreditAllocation.earnedHours.toFixed(2), used: shabbatCreditAllocation.usedHours.toFixed(2), diff --git a/src/hooks/useShabbatCreditAllocation.ts b/src/hooks/useShabbatCreditAllocation.ts deleted file mode 100644 index d94429e..0000000 --- a/src/hooks/useShabbatCreditAllocation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import { useSelector } from "react-redux"; - -import { allocateShabbatCredit } from "@/domain"; -import { RootState } from "@/redux/store"; - -export const useShabbatCreditAllocation = () => { - const workDays = useSelector((state: RootState) => state.workDays.workDays); - const dailyPayMaps = useSelector( - (state: RootState) => state.global.dailyPayMaps, - ); - const standardHours = useSelector( - (state: RootState) => state.global.config.standardHours, - ); - - return useMemo( - () => - allocateShabbatCredit({ workDays, dailyPayMaps, standardHours }), - [workDays, dailyPayMaps, standardHours], - ); -}; diff --git a/src/i18n/locales/en/work-table.json b/src/i18n/locales/en/work-table.json index e0301ad..6781aff 100644 --- a/src/i18n/locales/en/work-table.json +++ b/src/i18n/locales/en/work-table.json @@ -67,8 +67,9 @@ "hint_cross_midnight": "Check ✅ for shifts crossing midnight", "hint_duty_shift": "Click 🚗 to mark a duty shift (meal allowance eligibility)", "hint_save_shift": "Click 💾 to save changes and update salary", + "shabbat_credit_carried_over": "Carried over from last month: {{hours}} hours.", "shabbat_credit_summary": "Shabbat credit — earned: {{earned}}, used: {{used}}, unused: {{unused}}.", - "shabbat_credit_unused_note": "Unused Shabbat credit is not included in total hours or salary.", + "shabbat_credit_unused_note": "Unused Shabbat credit hours are not included in this month's total hours or salary — they carry over to next month.", "add_shift_label": "Add shift", "total_gross_label": "Total gross" }, diff --git a/src/i18n/locales/he/work-table.json b/src/i18n/locales/he/work-table.json index da0739b..6a716be 100644 --- a/src/i18n/locales/he/work-table.json +++ b/src/i18n/locales/he/work-table.json @@ -67,8 +67,9 @@ "hint_cross_midnight": "סמן ✅ למשמרת שחוצה את חצות", "hint_duty_shift": "לחץ על 🚗 לסימון משמרת בתפקיד (זכאות אש״ל)", "hint_save_shift": "לחץ על 💾 לשמירת שינויים ועדכון שכר", + "shabbat_credit_carried_over": "הועברו מהחודש הקודם: {{hours}} שעות.", "shabbat_credit_summary": "זכות שבת — נצברו: {{earned}}, נוצלו: {{used}}, לא נוצלו: {{unused}}.", - "shabbat_credit_unused_note": "שעות זכות שבת שלא נוצלו אינן נכללות בסך השעות או בחישוב השכר.", + "shabbat_credit_unused_note": "שעות זכות שבת שלא נוצלו אינן נכללות בסך השעות או בחישוב השכר של החודש הנוכחי — הן יועברו לחודש הבא.", "add_shift_label": "הוסף משמרת", "total_gross_label": "סה״כ ברוטו" }, diff --git a/src/test/ui/components/MonthlySalarySummary.test.tsx b/src/test/ui/components/MonthlySalarySummary.test.tsx index 48d902e..0466f0d 100644 --- a/src/test/ui/components/MonthlySalarySummary.test.tsx +++ b/src/test/ui/components/MonthlySalarySummary.test.tsx @@ -18,7 +18,6 @@ vi.mock("@/hooks", () => ({ year: 2025, month: 1, }), - useShabbatCreditAllocation: () => ({ usedHours: 0 }), })); vi.mock("@/features/salary-summary", () => ({ @@ -31,6 +30,7 @@ vi.mock("@/features/salary-summary", () => ({ handleTotalChange: vi.fn(), monthlyTotal: 5000, }), + useShabbatCreditAllocation: () => ({ usedHours: 0 }), })); vi.mock("@/utils", () => ({ diff --git a/src/test/ui/features/useShabbatCreditAllocation.test.tsx b/src/test/ui/features/useShabbatCreditAllocation.test.tsx new file mode 100644 index 0000000..3aa2d6d --- /dev/null +++ b/src/test/ui/features/useShabbatCreditAllocation.test.tsx @@ -0,0 +1,166 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMock = vi.hoisted(() => ({ user: null as { id: string } | null })); + +const globalStateMock = vi.hoisted(() => ({ + year: 2026, + month: 9, + standardHours: 8, +})); + +const snackbarMock = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +const domainMock = vi.hoisted(() => ({ + services: { + dateService: { + getPreviousMonth: (year: number, month: number) => + month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 }, + }, + }, +})); + +const monthlyConfigServiceMock = vi.hoisted(() => ({ + fetch: vi.fn(), + setUnusedShabbatCreditHours: vi.fn(), +})); + +const reduxStateMock = vi.hoisted(() => ({ + workDays: { workDays: [] as unknown[] }, + global: { + dailyPayMaps: {} as Record, + config: { standardHours: 8 }, + }, +})); + +vi.mock("@/hooks/useAuth", () => ({ useAuth: () => authMock })); +vi.mock("@/hooks/useGlobalState", () => ({ useGlobalState: () => globalStateMock })); +vi.mock("@/hooks/useAppSnackbar", () => ({ useAppSnackbar: () => snackbarMock })); +vi.mock("@/hooks/useDomain", () => ({ useDomain: () => domainMock })); +vi.mock("@/services", () => ({ monthlyConfigService: () => monthlyConfigServiceMock })); +vi.mock("react-redux", () => ({ + useSelector: (selector: (state: typeof reduxStateMock) => unknown) => + selector(reduxStateMock), +})); + +import { useShabbatCreditAllocation } from "@/features/salary-summary/hooks/useShabbatCreditAllocation"; + +describe("useShabbatCreditAllocation", () => { + beforeEach(() => { + authMock.user = null; + globalStateMock.year = 2026; + globalStateMock.month = 9; + reduxStateMock.workDays.workDays = []; + reduxStateMock.global.dailyPayMaps = {}; + snackbarMock.error.mockReset(); + monthlyConfigServiceMock.fetch.mockReset(); + monthlyConfigServiceMock.setUnusedShabbatCreditHours + .mockReset() + .mockReturnValue({ call: () => Promise.resolve({ data: null }) }); + }); + + it("carries no hours in guest mode", async () => { + const { result } = renderHook(() => useShabbatCreditAllocation()); + await act(async () => { + await Promise.resolve(); + }); + + expect(monthlyConfigServiceMock.fetch).not.toHaveBeenCalled(); + expect(result.current.carriedOverHours).toBe(0); + }); + + it("fetches last month's unused balance and folds it into the total available hours", async () => { + authMock.user = { id: "user-1" }; + monthlyConfigServiceMock.fetch.mockReturnValue({ + call: () => + Promise.resolve({ + data: { + year: 2026, + month: 8, + standard_hours: 8, + base_rate: 0, + unused_shabbat_credit_hours: 3.5, + }, + }), + }); + + const { result } = renderHook(() => useShabbatCreditAllocation()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(monthlyConfigServiceMock.fetch).toHaveBeenCalledWith("user-1", 2026, 8); + expect(result.current.carriedOverHours).toBe(3.5); + expect(result.current.totalAvailableHours).toBe(3.5); + }); + + it("does not write back before the carry-over fetch has resolved", async () => { + authMock.user = { id: "user-1" }; + let resolveFetch: (value: { data: null }) => void = () => {}; + monthlyConfigServiceMock.fetch.mockReturnValue({ + call: () => new Promise((resolve) => (resolveFetch = resolve)), + }); + + renderHook(() => useShabbatCreditAllocation()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(monthlyConfigServiceMock.setUnusedShabbatCreditHours).not.toHaveBeenCalled(); + + await act(async () => { + resolveFetch({ data: null }); + await Promise.resolve(); + }); + }); + + it("writes this month's own unused balance back once resolved", async () => { + authMock.user = { id: "user-1" }; + monthlyConfigServiceMock.fetch.mockReturnValue({ + call: () => Promise.resolve({ data: null }), + }); + reduxStateMock.global.dailyPayMaps = { + "2026-09-01": { + totalHours: 0, + earnedShabbatCredit: { percent: 1, hours: 4 }, + }, + }; + + renderHook(() => useShabbatCreditAllocation()); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(monthlyConfigServiceMock.setUnusedShabbatCreditHours).toHaveBeenCalledWith( + "user-1", + 2026, + 9, + 4, + ); + }); + + it("shows an error and treats the carry-over as zero when the fetch fails", async () => { + authMock.user = { id: "user-1" }; + monthlyConfigServiceMock.fetch.mockReturnValue({ + call: () => Promise.resolve({ error: "connection lost" }), + }); + + const { result } = renderHook(() => useShabbatCreditAllocation()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(snackbarMock.error).toHaveBeenCalledWith("connection lost"); + expect(result.current.carriedOverHours).toBe(0); + }); +}); From 10fb7104d78a9a9f227095218f7a3218b237d790 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:25:03 +0300 Subject: [PATCH 7/9] docs(privacy): clarify authenticated data in Supabase is not anonymous --- src/i18n/locales/en/common.json | 6 +++--- src/i18n/locales/he/common.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 67b5043..0feb17a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -65,11 +65,11 @@ "privacy": { "title": "Privacy & Data Usage", "paragraphs": [ - "The system does not store personal information, salary data, work hours, or user identification details.", - "The system does not perform user identification and does not use cookies for advertising or cross-site tracking purposes.", + "The system supports optional sign-in through your Google account. If you sign in, Supabase processes your email address, your Google account identifier, and session metadata to manage your account.", + "If you sign in, your shifts, day statuses (sick, vacation), and monthly settings (standard hours, hourly rate) are stored remotely in Supabase and are associated with your account — this data is not anonymous. If you do not sign in, nothing is stored remotely and everything is lost when you close or refresh the page. The system does not use cookies for advertising or cross-site tracking purposes.", "This site uses Google Analytics, a product by Google that helps website owners understand how visitors engage with their sites. When you visit, your browser automatically sends certain data to Google, including the page URL and your IP address.", "Google Analytics uses cookies — text files placed on your device — to analyze how visitors use the site. The information collected through cookies is transmitted to and stored on Google's servers. Google uses this data to evaluate site usage, compile activity reports, and provide other services related to site and internet usage.", - "Only basic interactions are tracked (such as page views and general interactions) to improve calculation clarity and user experience. Google Analytics 4 automatically anonymizes IP addresses. Collected data is aggregated and does not allow identification of a person, pay slip, or specific workplace.", + "Only basic interactions are tracked (such as page views and general interactions) to improve calculation clarity and user experience. Google Analytics 4 automatically anonymizes IP addresses, and this tracking stays anonymous whether or not you are signed in — it is never linked to your account or identity. Collected data is aggregated and does not allow identification of a person, pay slip, or specific workplace.", "You may disable cookies through your browser settings, though some site features may not be available as a result. By continuing to browse this site, you consent to the collection of data by Google as described above." ] } diff --git a/src/i18n/locales/he/common.json b/src/i18n/locales/he/common.json index 66cad9d..b356049 100644 --- a/src/i18n/locales/he/common.json +++ b/src/i18n/locales/he/common.json @@ -65,11 +65,11 @@ "privacy": { "title": "פרטיות ושימוש בנתונים", "paragraphs": [ - "המערכת אינה שומרת מידע אישי, נתוני שכר, שעות עבודה או פרטי זיהוי של המשתמש.", - "המערכת אינה מבצעת זיהוי משתמשים ואינה עושה שימוש בעוגיות לצורכי פרסום או מעקב חוצה אתרים.", + "המערכת מאפשרת התחברות אופציונלית באמצעות חשבון Google. בעת התחברות, Supabase מעבדת את כתובת הדואר האלקטרוני שלך, מזהה חשבון ה-Google שלך ונתוני ההתחברות לצורך ניהול החשבון.", + "אם אתה מתחבר, המשמרות, סטטוס הימים (מחלה, חופשה) וההגדרות החודשיות שלך (שעות תקן, שכר שעתי) נשמרים מרחוק ב-Supabase ומקושרים לחשבון שלך — מידע זה אינו אנונימי. אם אינך מתחבר, שום דבר לא נשמר מרחוק וכל הנתונים אובדים בסגירת הדף או ברענון שלה. המערכת אינה עושה שימוש בעוגיות לצורכי פרסום או מעקב חוצה אתרים.", "אתר זה משתמש בגוגל אנליטיקס, מוצר של גוגל שעוזר לבעלי אתרים להבין את אופן המעורבות של לקוחות באתרים שלהם. כאשר אתם מבקרים באתר זה הדפדפן שלכם שולח נתונים מסוימים ל-Google באופן אוטומטי. נתונים אלה כוללים, בין היתר, את כתובת האינטרנט של הדף שבו אתם מבקרים ואת כתובת ה-IP שלכם.", "גוגל אנליטיקס משתמשים בקבצי \"cookies\", קבצי טקסט המוצבים על המחשב במטרה לסייע לאתר לנתח כיצד המבקרים משתמשים באתר. האינפורמציה שנאספת דרך קובץ ה-\"cookie\" מועברת ונשמרת בשרתי גוגל. גוגל משתמשת במידע זה כדי להעריך את השימוש שלך באתר, עריכת דוחות על פעילות האתר ועל השימוש באינטרנט בשביל מנהלי אתרים.", - "נאספות אינטראקציות בסיסיות בלבד (כגון צפיות בעמודים ואינטראקציות כלליות) לשיפור בהירות החישוב וחוויית המשתמש. Google Analytics 4 מבצע אנונימיזציה אוטומטית של כתובות IP. הנתונים הנאספים הם מצטברים ואינם מאפשרים זיהוי של אדם, תלוש או מקום עבודה ספציפי.", + "נאספות אינטראקציות בסיסיות בלבד (כגון צפיות בעמודים ואינטראקציות כלליות) לשיפור בהירות החישוב וחוויית המשתמש. Google Analytics 4 מבצע אנונימיזציה אוטומטית של כתובות IP, והמעקב הזה נשאר אנונימי גם אם אתה מחובר לחשבון — הוא לעולם אינו מקושר לזהותך או לחשבונך. הנתונים הנאספים הם מצטברים ואינם מאפשרים זיהוי של אדם, תלוש או מקום עבודה ספציפי.", "ניתן לבטל את השימוש בקבצי \"cookies\" על ידי בחירת האפשרות המתאימה בהגדרות הדפדפן, אולם במקרה כזה ייתכן ולא כל המידע המופיע באתר יהיה זמין. גלישה באתר זה מהווה את הסכמתכם לאיסוף המידע על ידי גוגל באופן ולמען השימושים שצוינו לעיל." ] } From 4e7e974bb47d4af4b8284a1987915675a165600a Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:25:36 +0300 Subject: [PATCH 8/9] docs(readme): document Supabase auth and persisted tables --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--- README_HE.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d3b4f79..a9fcc38 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ![Redux](https://img.shields.io/badge/Redux_Toolkit-2.11.0-764ABC?logo=redux&logoColor=white) ![MUI](https://img.shields.io/badge/Material_UI-7.0.2-007FFF?logo=mui&logoColor=white) ![Vitest](https://img.shields.io/badge/Vitest-4-6E9F18?logo=vitest&logoColor=white) +![Supabase](https://img.shields.io/badge/Supabase-2.112.4-3ECF8E?logo=supabase&logoColor=white) [![CI](https://github.com/dmaman86/shiftly/actions/workflows/ci.yml/badge.svg)](https://github.com/dmaman86/shiftly/actions/workflows/ci.yml) ![TypeScript](https://img.shields.io/badge/TypeScript-5.7.2-blue?logo=typescript) @@ -67,6 +68,28 @@ This makes it possible to **recalculate past months accurately** using the same - Monthly aggregated breakdown - Incremental recalculation (add / update / remove shifts) - Fully reactive UI +- Optional Google sign-in with cross-device data persistence + +--- + +## Authentication & Data Persistence + +Shiftly works fully **without an account** — everything runs in memory, and nothing is stored anywhere. + +Signing in with a **Google account** (via Supabase Auth) additionally persists your data to Supabase, tied to your account, so it carries over across sessions and devices: + +- **Monthly configuration** — year, month, standard hours, hourly rate +- **Day status** — sick / vacation marks per day +- **Shifts** — start, end, and duty flag for each saved shift +- **Shabbat credit carry-over** — unused Shabbat credit hours roll forward to the next month instead of being lost + +| Table | Stores | +| --- | --- | +| `monthly_configs` | Per-(user, year, month) settings, plus the running Shabbat-credit carry-over balance | +| `work_days` | Per-(user, date) status (`sick` / `vacation`) — a missing row means `normal` | +| `shifts` | Per-user saved shifts, with the date denormalized onto each row for direct querying | + +All three tables are protected by Postgres Row Level Security: each user can only read or write their own rows. The schema lives in `supabase/migrations/`. --- @@ -243,6 +266,7 @@ Create specific calculator instances: - **Axios** 1.18.1 (HTTP client) - **date-fns** 4.1.0 (date manipulation) - **Hebcal API** (holiday detection) +- **Supabase** 2.112.4 (Google OAuth + Postgres persistence) ### Testing @@ -301,6 +325,19 @@ bun run dev Visit `http://localhost:5173/shiftly` in your browser. +### Supabase Setup (optional) + +Guest mode works with no setup at all — nothing is persisted, no account required. + +To enable Google sign-in and cross-device data persistence, create a `.env.local` file with your Supabase project's credentials: + +```bash +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` + +Then run the SQL files under `supabase/migrations/`, in order, in your Supabase project's SQL Editor to create the `monthly_configs`, `work_days`, and `shifts` tables with their Row Level Security policies. + --- ## Project Structure @@ -310,6 +347,7 @@ src/ ├── app/ # Application shell │ ├── domain/ # Domain instance & wiring │ ├── providers/ # Context providers +│ │ └── auth/ # Supabase auth context & provider │ └── routes/ # Route configuration ├── domain/ # Business logic (framework-agnostic) │ ├── builder/ # Domain structure builders @@ -326,11 +364,12 @@ src/ │ └── types/ # Type definitions ├── adapters/ # Domain -> UI view models ├── features/ # Feature-specific UI modules +│ ├── auth/ # Google sign-in UI │ ├── calculation-rules/ -│ ├── config/ +│ ├── config/ # Config panel + monthly-config persistence sync │ ├── info-dialog/ │ ├── salary-summary/ -│ ├── work-table/ +│ ├── work-table/ # Work table + day-status/shift persistence sync │ └── workday-timeline/ ├── hooks/ # React hooks (orchestration layer) ├── hoc/ # Higher-order components @@ -340,9 +379,16 @@ src/ │ └── states/ # Redux slices ├── services/ # External services │ ├── analytics/ # Salary feedback service -│ └── hebcal/ # Holiday API integration +│ ├── hebcal/ # Holiday API integration +│ ├── supabase/ # Supabase client +│ ├── monthlyConfig/# Monthly config persistence +│ ├── workDay/ # Day status persistence +│ └── shift/ # Shift persistence ├── constants/ # Application constants └── utils/ # Helper utilities + +supabase/ +└── migrations/ # Postgres schema (tables, RLS policies) ``` --- diff --git a/README_HE.md b/README_HE.md index 881f6c6..9648b1a 100644 --- a/README_HE.md +++ b/README_HE.md @@ -9,6 +9,7 @@ ![Redux](https://img.shields.io/badge/Redux_Toolkit-2.11.0-764ABC?logo=redux&logoColor=white) ![MUI](https://img.shields.io/badge/Material_UI-7.0.2-007FFF?logo=mui&logoColor=white) ![Vitest](https://img.shields.io/badge/Vitest-4-6E9F18?logo=vitest&logoColor=white) +![Supabase](https://img.shields.io/badge/Supabase-2.112.4-3ECF8E?logo=supabase&logoColor=white) [![CI](https://github.com/dmaman86/shiftly/actions/workflows/ci.yml/badge.svg)](https://github.com/dmaman86/shiftly/actions/workflows/ci.yml) ![TypeScript](https://img.shields.io/badge/TypeScript-5.7.2-blue?logo=typescript) @@ -68,6 +69,28 @@ - פירוט חודשי מצטבר - חישוב אינקרמנטלי (הוספה / עדכון / הסרה של משמרות) - ממשק משתמש ריאקטיבי לחלוטין +- התחברות אופציונלית עם Google ושמירת נתונים בין מכשירים + +--- + +## אימות ושמירת נתונים + +Shiftly פועלת במלואה **גם ללא חשבון** — הכל רץ בזיכרון, ושום דבר לא נשמר. + +התחברות עם **חשבון Google** (דרך Supabase Auth) שומרת בנוסף את הנתונים שלך ב-Supabase, מקושרים לחשבון שלך, כך שהם נשארים זמינים בין הפעלות ובין מכשירים: + +- **הגדרות חודשיות** — שנה, חודש, שעות תקן, שכר שעתי +- **סטטוס יום** — סימון מחלה / חופשה לכל יום +- **משמרות** — שעת התחלה, סיום ודגל תפקיד לכל משמרת שנשמרה +- **העברת זכות שבת** — שעות זכות שבת שלא נוצלו עוברות לחודש הבא במקום להיעלם + +| טבלה | שומרת | +| --- | --- | +| `monthly_configs` | הגדרות לכל (משתמש, שנה, חודש), וגם יתרת זכות השבת המועברת | +| `work_days` | סטטוס לכל (משתמש, תאריך) (`sick` / `vacation`) — שורה חסרה משמעה `normal` | +| `shifts` | משמרות שנשמרו לכל משתמש, עם התאריך על כל שורה לצורך שאילתות ישירות | + +שלוש הטבלאות מוגנות באמצעות Row Level Security של Postgres: כל משתמש יכול לקרוא ולכתוב רק את השורות שלו. הסכמה נמצאת ב-`supabase/migrations/`. --- @@ -243,6 +266,7 @@ totalHours = worked hours + sick hours + vacation hours + appliedShabbatCredit - **Axios** 1.18.1 (HTTP client) - **date-fns** 4.1.0 (טיפול בתאריכים) - **Hebcal API** (זיהוי חגים) +- **Supabase** 2.112.4 (התחברות Google + שמירת נתונים ב-Postgres) ### בדיקות @@ -258,6 +282,7 @@ src/ ├── app/ # מעטפת האפליקציה │ ├── domain/ # אתחול וחיבור הדומיין │ ├── providers/ # ספקי Context +│ │ └── auth/ # Context ו-Provider לאימות Supabase │ └── routes/ # הגדרות ניתוב ├── domain/ # לוגיקה עסקית (בלתי תלויה בפריימוורקים) │ ├── builder/ # בוני מבני דומיין @@ -274,11 +299,12 @@ src/ │ └── types/ # הגדרות טיפוסים ├── adapters/ # המרת דומיין ל־UI ├── features/ # מודולי UI ספציפיים +│ ├── auth/ # ממשק התחברות Google │ ├── calculation-rules/ -│ ├── config/ +│ ├── config/ # פאנל הגדרות + סנכרון שמירת קונפיג חודשי │ ├── info-dialog/ │ ├── salary-summary/ -│ ├── work-table/ +│ ├── work-table/ # טבלת עבודה + סנכרון שמירת סטטוס יום ומשמרות │ └── workday-timeline/ ├── hooks/ # React hooks (שכבת תיאום) ├── hoc/ # רכיבי Higher-order @@ -288,9 +314,16 @@ src/ │ └── states/ # Redux slices ├── services/ # שירותים חיצוניים │ ├── analytics/ # שירות משוב שכר -│ └── hebcal/ # אינטגרציה עם API חגים +│ ├── hebcal/ # אינטגרציה עם API חגים +│ ├── supabase/ # קליינט Supabase +│ ├── monthlyConfig/# שמירת קונפיג חודשי +│ ├── workDay/ # שמירת סטטוס יום +│ └── shift/ # שמירת משמרות ├── constants/ # קבועי אפליקציה └── utils/ # עזרי שירות + +supabase/ +└── migrations/ # סכמת Postgres (טבלאות, מדיניות RLS) ``` --- @@ -448,6 +481,19 @@ bun run dev כניסה ל־`http://localhost:5173/shiftly` בדפדפן. +### הגדרת Supabase (אופציונלי) + +מצב אורח עובד בלי שום הגדרה — שום דבר לא נשמר, ואין צורך בחשבון. + +כדי להפעיל התחברות עם Google ושמירת נתונים בין מכשירים, צרו קובץ `.env.local` עם פרטי הפרויקט שלכם ב-Supabase: + +```bash +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` + +לאחר מכן הריצו את קבצי ה-SQL תחת `supabase/migrations/`, לפי הסדר, ב-SQL Editor של פרויקט ה-Supabase שלכם, כדי ליצור את הטבלאות `monthly_configs`, `work_days` ו-`shifts` עם מדיניות ה-Row Level Security שלהן. + --- ## רישיון From 24f91cda0c44787a8651a01d1ab7458055eb3842 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 30 Aug 2026 16:31:16 +0300 Subject: [PATCH 9/9] fix(auth): don't crash on import when Supabase env vars are missing --- src/services/supabase/supabase.client.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/services/supabase/supabase.client.ts b/src/services/supabase/supabase.client.ts index e76cf60..30347c2 100644 --- a/src/services/supabase/supabase.client.ts +++ b/src/services/supabase/supabase.client.ts @@ -3,8 +3,19 @@ import { createClient } from "@supabase/supabase-js"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; +// Auth and persistence are optional (guest mode works with zero config), so +// this must not throw when unconfigured - that would crash every consumer of +// this module (tests, CI, a fresh clone without .env.local) even for guests +// who never touch Supabase. A placeholder client is inert until someone +// actually tries to sign in, at which point it fails as a normal network/auth +// error instead of an import-time crash. if (!supabaseUrl || !supabasePublishableKey) { - throw new Error("Supabase environment variables are not configured."); + console.warn( + "Supabase environment variables are not configured - sign-in and data persistence are disabled. Guest mode is unaffected.", + ); } -export const supabase = createClient(supabaseUrl, supabasePublishableKey); +export const supabase = createClient( + supabaseUrl || "https://placeholder.supabase.co", + supabasePublishableKey || "placeholder-anon-key", +);