Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import js from '@eslint/js'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
export default defineConfig(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
Expand All @@ -13,16 +14,32 @@ export default tseslint.config(
ecmaVersion: 2020,
globals: globals.browser,
},
settings: {
'react-hooks': {
additionalEffectHooks: '(useAsync)',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
{
// These components intentionally synchronize editable state with external data.
files: [
'src/features/config/ConfigPanel.tsx',
'src/features/salary-summary/hooks/usePayTableVM.ts',
],
rules: {
'react-hooks/set-state-in-effect': 'off',
},
},
)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.19",
"gh-pages": "^6.3.0",
"globals": "^15.15.0",
Expand Down
13 changes: 10 additions & 3 deletions src/features/config/ConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,18 @@ export const ConfigPanel = ({ domain, mode }: ConfigPanelProps) => {

const availableMonths = monthResolver.getAvailableMonthOptions(year);

const currentYear = monthResolver.getCurrentYear();
const parsedYear = Number(inputsValues.yearInput);
const yearError = !isNaN(parsedYear) && parsedYear < SYSTEM_START_YEAR;
const yearHelperText = yearError
const yearBelowMinimum =
!Number.isNaN(parsedYear) && parsedYear < SYSTEM_START_YEAR;
const yearAboveMaximum =
!Number.isNaN(parsedYear) && parsedYear > currentYear;
const yearError = yearBelowMinimum || yearAboveMaximum;
const yearHelperText = yearBelowMinimum
? t("config.year_min_error", { year: SYSTEM_START_YEAR })
: "";
: yearAboveMaximum
? t("config.year_max_error", { year: currentYear })
: "";

const helperTextBaseRate = (): string => {
if (baseRate === 0) {
Expand Down
25 changes: 11 additions & 14 deletions src/hooks/useAsync.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,28 @@
import { useEffect, useRef, DependencyList } from "react";
import { useEffect, useEffectEvent, type DependencyList } from "react";

export const useAsync = <T>(
asyncRequest: () => Promise<T>,
deps: DependencyList,
onResult: (response: T) => void,
deps: DependencyList = [],
cleanup?: () => void,
) => {
const asyncRequestRef = useRef(asyncRequest);
const onResultRef = useRef(onResult);
const cleanupRef = useRef(cleanup);
const asyncRequestEvent = useEffectEvent(asyncRequest);
const onResultEvent = useEffectEvent(onResult);
const cleanupEvent = useEffectEvent(() => cleanup?.());

asyncRequestRef.current = asyncRequest;
onResultRef.current = onResult;
cleanupRef.current = cleanup;

// deps controls WHEN to fire; refs ensure no stale closures on the callbacks.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
let isActive = true;

asyncRequestRef.current().then((result) => {
if (isActive) onResultRef.current(result);
void asyncRequestEvent().then((result) => {
if (isActive) onResultEvent(result);
});

return () => {
isActive = false;
cleanupRef.current?.();
cleanupEvent();
};

// Dependencies are statically validated at useAsync call sites.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
};
1 change: 1 addition & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"standard_hours_helper": "Default: 6.67",
"base_rate_label": "Hourly Rate",
"year_min_error": "Minimum supported year is {{year}}",
"year_max_error": "Maximum supported year is {{year}}",
"base_rate_helper_daily": "Enter hourly rate to display daily or monthly salary",
"base_rate_helper_monthly": "Monthly salary calculation requires setting hourly rate",
"calculation_based_on": "Calculation based on {{monthName}} {{year}}"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/he/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"standard_hours_helper": "ברירת מחדל: {{standardHours}}",
"base_rate_label": "שכר שעתי",
"year_min_error": "השנה המינימלית הנתמכת היא {{year}}",
"year_max_error": "השנה המקסימלית הנתמכת היא {{year}}",
"base_rate_helper_daily": "יש להזין שכר שעתי להצגת שכר יומי או חודשי",
"base_rate_helper_monthly": "חישוב שכר חודשי מחייב הגדרת שכר שעתי",
"calculation_based_on": "החישוב מבוסס על {{monthName}} {{year}}"
Expand Down
51 changes: 30 additions & 21 deletions src/pages/DailyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ import { DomainContextType } from "@/app";
import { hebcalService, analyticsService } from "@/services";
import { ErrorBoundary, FeatureErrorFallback } from "@/layout";

const calendarApi = hebcalService();

export const DailyPage = ({ domain }: { domain: DomainContextType }) => {
const { t } = useTranslation("work-table");
const { dateService } = domain.services;
const { isMobile } = useDeviceType();

const call = hebcalService();
const { year, month, baseRate, reset } = useGlobalState();

const { workDays, generate } = useWorkDays();
Expand All @@ -50,33 +51,41 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => {

const { loading, callEndPoint, cancelEndPoint } = useFetch();

const handleCalendarResult = ({
data,
error,
}: ApiResponse<CalendarEventMap>) => {
if (data) {
generate(year, month, data);
reset();
setError(undefined);
return;
}

const description = error ?? "hebcal fetch failed";

analyticsService.track({
name: "exception",
params: {
description,
fatal: false,
error_type: "hebcal_api_error",
},
});

setError(description);
};

useAsync<ApiResponse<CalendarEventMap>>(
() => {
const { startDate, endDate } = dateService.getDatesRange(year, month);
return callEndPoint<CalendarEventMap>(
call.getData(startDate, endDate),
calendarApi.getData(startDate, endDate),
buildEventMap,
);
},
({ data, error }) => {
if (data) {
// console.log(data);
generate(year, month, data);
reset();
setError(undefined);
return;
}
analyticsService.track({
name: "exception",
params: {
description: error ?? "hebcal fetch failed",
fatal: false,
error_type: "hebcal_api_error",
},
});
setError(error);
},
[year, month],
[dateService, year, month, callEndPoint],
handleCalendarResult,
cancelEndPoint,
);

Expand Down
50 changes: 48 additions & 2 deletions src/test/ui/components/ConfigPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders, screen, createMockGlobalState, waitFor } from "@/test/ui/utils";
import { describe, it, expect, vi } from "vitest";
import {
act,
createMockGlobalState,
fireEvent,
renderWithProviders,
screen,
waitFor,
} from "@/test/ui/utils";
import userEvent from "@testing-library/user-event";
import { ConfigPanel } from "@/features/config/ConfigPanel";
import { pipelineInstance } from "@/test/ui/utils/setup-domain";
Expand Down Expand Up @@ -237,6 +244,45 @@ describe("ConfigPanel", () => {
}, { timeout: 200 });
});

it("should show an error for a year after the current year", () => {
vi.useFakeTimers();

try {
const currentYear = mockDomain.resolvers.monthResolver.getCurrentYear();
const { store } = renderWithProviders(
<ConfigPanel domain={mockDomain} />,
{
preloadedState: {
global: createMockGlobalState({
config: {
year: 2024,
month: 1,
standardHours: 6.67,
baseRate: 50,
},
}),
},
},
);

const yearInput = screen.getByLabelText("שנה");
fireEvent.change(yearInput, {
target: { value: String(currentYear + 1) },
});

expect(
screen.getByText(`השנה המקסימלית הנתמכת היא ${currentYear}`),
).toBeInTheDocument();
expect(yearInput).toHaveAttribute("aria-invalid", "true");

act(() => vi.advanceTimersByTime(500));

expect(store.getState().global.config.year).toBe(2024);
} finally {
vi.useRealTimers();
}
});

it("should show helper text for zero base rate in daily mode", () => {
renderWithProviders(<ConfigPanel domain={mockDomain} mode="daily" />, {
preloadedState: {
Expand Down
Loading
Loading