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
5 changes: 4 additions & 1 deletion docs/DAILY_SCHEDULE_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ are a **framework-agnostic** surface:
type DailyScheduleEditState = {
rows: DailyScheduleEditFormRow[]; // one row per weekday, plain values
unsavedSummaryDays: DailyScheduleSummaryDay[]; // live preview while editing
draftWeeklyHours: number; // weekly total of the draft being edited
savedWeeklyHours: number; // weekly total of the saved schedule
hoursRangeError: DailyScheduleHoursError | null;
isDirty: boolean;
hasDifferencesFromCountryDefault: boolean; // also exposed as `isDirty`
hasUnsavedChanges: boolean; // draft differs from the saved schedule
formError: string | null;
hasFieldErrors: boolean;
getFieldError: (index, field) => string | undefined;
Expand Down
17 changes: 16 additions & 1 deletion example/src/flows/Onboarding/CustomDailyScheduleExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
} from '@remoteoss/remote-flows/internals';
import { WEEKDAY_LABELS } from '@remoteoss/remote-flows';

const confirmDiscard = (hasUnsavedChanges: boolean) =>
!hasUnsavedChanges || window.confirm('Discard your unsaved changes?');

function CustomDailyScheduleEditForm({
editBag,
subtractBreaksFromWorkHours,
Expand Down Expand Up @@ -115,6 +118,12 @@ function CustomDailyScheduleEditForm({
))}
</div>

{/* Weekly totals straight from state, no need to recalculate */}
<p className='text-sm text-gray-600'>
Weekly total: <strong>{state.draftWeeklyHours}h</strong>
{state.hasUnsavedChanges && ` (saved: ${state.savedWeeklyHours}h)`}
</p>

{/* Live preview using state.unsavedSummaryDays */}
<div className='p-4 bg-gray-50 rounded'>
<h4 className='font-semibold mb-2'>Preview:</h4>
Expand Down Expand Up @@ -143,7 +152,7 @@ function CustomDailyScheduleEditForm({

{/* Actions */}
<div className='flex gap-2'>
{state.isDirty && (
{state.hasDifferencesFromCountryDefault && (
<button
type='button'
onClick={actions.reset}
Expand All @@ -155,6 +164,9 @@ function CustomDailyScheduleEditForm({
<button
type='button'
onClick={() => {
if (!confirmDiscard(state.hasUnsavedChanges)) {
return;
}
actions.close();
onClose();
}}
Expand Down Expand Up @@ -205,6 +217,9 @@ export function CustomDailySchedule(props: DailyScheduleRenderProps) {
type='button'
onClick={() => {
if (isEditing) {
if (!confirmDiscard(editBag.state.hasUnsavedChanges)) {
return;
}
editBag.actions.close();
}
setIsEditing(!isEditing);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export const DailyScheduleEditForm = ({
)}

<div className='flex items-center gap-4 pt-4'>
{state.isDirty && (
{state.hasDifferencesFromCountryDefault && (
<Button
type='button'
variant='ghost'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { DailyScheduleContainer } from '@/src/flows/Onboarding/components/DailyS
import { germanyDailyScheduleMetadata } from '@/src/flows/Onboarding/components/DailySchedule/tests/fixtures';
import {
DailyScheduleContainerProps,
DailyScheduleEditBag,
DailyScheduleRenderProps,
} from '@/src/flows/Onboarding/components/DailySchedule/types';
import { TestProviders } from '@/src/tests/testHelpers';
import { render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';

Expand Down Expand Up @@ -927,3 +928,122 @@ describe('DailySchedule', () => {
});
});
});

describe('Edit state change flags', () => {
let editBag: DailyScheduleEditBag;

const CapturingField = (
props: Omit<DailyScheduleContainerProps, 'render'>,
) => (
<DailyScheduleContainer
{...(props as $TSFixMe as DailyScheduleContainerProps)}
render={(renderProps: DailyScheduleRenderProps) => {
editBag = renderProps.editBag;
return null;
}}
/>
);

const renderEditBag = (dailySchedule?: Record<string, unknown>) =>
renderWithForm([createDailyScheduleField({ Component: CapturingField })], {
daily_schedule: dailySchedule,
});

const mondayOnlySchedule = {
selected_days: ['monday'],
schedule: {
monday: {
start_time: '10:00',
end_time: '14:00',
break_duration_minutes: 15,
},
},
};

it('reports no unsaved changes for a saved custom schedule, while still differing from the country default', () => {
renderEditBag(mondayOnlySchedule);

expect(editBag.state.hasUnsavedChanges).toBe(false);
expect(editBag.state.hasDifferencesFromCountryDefault).toBe(true);
expect(editBag.state.isDirty).toBe(true);
});

it.each([
['start_time', '11:30'],
['break_duration_minutes', '30'],
] as const)('flags unsaved changes once %s is edited', (field, edited) => {
renderEditBag(mondayOnlySchedule);

act(() => editBag.actions.updateRow(0, field, edited));

expect(editBag.state.hasUnsavedChanges).toBe(true);
});

it('ignores edits to the times of an unchecked day, but counts toggling it', () => {
renderEditBag(mondayOnlySchedule);

act(() => editBag.actions.updateRow(1, 'start_time', '07:00'));
expect(editBag.state.hasUnsavedChanges).toBe(false);

act(() => editBag.actions.toggleDay(0));
expect(editBag.state.hasUnsavedChanges).toBe(true);
});

it('flags unsaved changes when a saved 0-minute break is cleared', () => {
renderEditBag({
...mondayOnlySchedule,
schedule: {
monday: {
...mondayOnlySchedule.schedule.monday,
break_duration_minutes: 0,
},
},
});

act(() => editBag.actions.updateRow(0, 'break_duration_minutes', ''));

expect(editBag.state.hasUnsavedChanges).toBe(true);
expect(editBag.state.hasDifferencesFromCountryDefault).toBe(true);
});

it('keeps the saved weekly hours fixed while the draft total follows edits', () => {
renderEditBag(mondayOnlySchedule);

const savedHours = editBag.state.savedWeeklyHours;
expect(savedHours).toBeGreaterThan(0);
expect(editBag.state.draftWeeklyHours).toBe(savedHours);

act(() => editBag.actions.updateRow(0, 'end_time', '15:00'));

expect(editBag.state.savedWeeklyHours).toBe(savedHours);
expect(editBag.state.draftWeeklyHours).toBe(savedHours + 1);
});

it('clears unsaved changes after close() discards the edits', () => {
renderEditBag(mondayOnlySchedule);

act(() => editBag.actions.updateRow(0, 'end_time', '15:00'));
expect(editBag.state.hasUnsavedChanges).toBe(true);

act(() => editBag.actions.close());
expect(editBag.state.hasUnsavedChanges).toBe(false);
});

it('matches the country default after reset(), but now differs from the saved schedule', () => {
renderEditBag(mondayOnlySchedule);

act(() => editBag.actions.reset());

expect(editBag.state.hasDifferencesFromCountryDefault).toBe(false);
expect(editBag.state.hasUnsavedChanges).toBe(true);
});

it('clears unsaved changes after save(), including a break typed with leading zeros', async () => {
renderEditBag(mondayOnlySchedule);

act(() => editBag.actions.updateRow(0, 'break_duration_minutes', '030'));
await act(async () => editBag.actions.save());

expect(editBag.state.hasUnsavedChanges).toBe(false);
});
});
10 changes: 9 additions & 1 deletion src/flows/Onboarding/components/DailySchedule/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,18 @@ export type DailyScheduleEditState = {
rows: DailyScheduleEditFormRow[];
/** Live preview of the schedule as summary days (unsaved changes) */
unsavedSummaryDays: DailyScheduleSummaryDay[];
/** Total weekly hours of the draft as it is being edited */
draftWeeklyHours: number;
/** Total weekly hours of the saved schedule, unaffected by unsaved edits */
savedWeeklyHours: number;
/** Weekly hours validation error for the current draft */
hoursRangeError: DailyScheduleHoursError | null;
/** Whether the form has unsaved changes from the default schedule */
/** Alias of `hasDifferencesFromCountryDefault`. It does not track unsaved edits; use `hasUnsavedChanges`. */
isDirty: boolean;
/** Whether the draft differs from the country's default schedule, e.g. to decide when to offer "Reset to default". */
hasDifferencesFromCountryDefault: boolean;
/** Whether the draft differs from the saved schedule, i.e. closing would discard edits. */
hasUnsavedChanges: boolean;
/** Form-level validation error (e.g., "Select at least one work day") */
formError: string | null;
/** Whether any fields have validation errors */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,39 @@ export function buildDailyScheduleEditFormDefaultValues({
});
}

/**
* True when `rows` matches the metadata-driven default schedule — ported from
* Dragon's `compareSchedules`/`dirty` tracking (`workScheduleFieldReducer.js`),
* which only shows its "Reset to default" button once the schedule actually
* deviates from the default, rather than showing it unconditionally.
*/
function isDefaultSchedule(
function toSummaryDays(
rows: DailyScheduleEditFormRowInternal[],
): DailyScheduleSummaryDay[] {
return rows
.filter((row) => row.checked)
.map((row) => ({
day: row.day,
start_time: row.start_time,
end_time: row.end_time,
break_duration_minutes: Number(row.break_duration_minutes) || 0,
}));
}

function isSameBreakMinutes(minutes: string, otherMinutes: string): boolean {
const trimmedMinutes = minutes.trim();
const trimmedOtherMinutes = otherMinutes.trim();

if (trimmedMinutes === '' || trimmedOtherMinutes === '') {
return trimmedMinutes === trimmedOtherMinutes;
}

return Number(trimmedMinutes) === Number(trimmedOtherMinutes);
}

/** True when both row sets describe the same schedule; unchecked days are equal whatever their times. */
function isSameSchedule(
rows: DailyScheduleEditFormRowInternal[],
defaultRows: DailyScheduleEditFormRowInternal[],
otherRows: DailyScheduleEditFormRowInternal[],
): boolean {
return rows.every((row, index) => {
const defaultRow = defaultRows[index];
const otherRow = otherRows[index];

if (row.checked !== defaultRow.checked) {
if (row.checked !== otherRow.checked) {
return false;
}

Expand All @@ -138,9 +157,12 @@ function isDefaultSchedule(
}

return (
row.start_time === defaultRow.start_time &&
row.end_time === defaultRow.end_time &&
row.break_duration_minutes === defaultRow.break_duration_minutes
row.start_time === otherRow.start_time &&
row.end_time === otherRow.end_time &&
isSameBreakMinutes(
row.break_duration_minutes,
otherRow.break_duration_minutes,
)
);
});
}
Expand Down Expand Up @@ -313,25 +335,27 @@ export function useDailyScheduleEditForm({

const formValues = watch('schedule');

const isDirty = !isDefaultSchedule(formValues, defaultScheduleRows);
const hasDifferencesFromCountryDefault = !isSameSchedule(
formValues,
defaultScheduleRows,
);
const hasUnsavedChanges = !isSameSchedule(formValues, savedScheduleRows);

// Same "checked rows -> summary days" shape the read-only summary and the
// edit modal's live preview both build from, kept here so `hoursError`
// reflects the schedule as the user is actively editing it.
const unsavedSummaryDays: DailyScheduleSummaryDay[] = watchedSchedule
.filter((row) => row.checked)
.map((row) => ({
day: row.day,
start_time: row.start_time,
end_time: row.end_time,
break_duration_minutes: Number(row.break_duration_minutes) || 0,
}));
const unsavedSummaryDays = toSummaryDays(watchedSchedule);

const totalWeeklyHours = calculateTotalWeeklyHours(
unsavedSummaryDays,
subtractBreaksFromWorkHours,
);

const savedWeeklyHours = calculateTotalWeeklyHours(
toSummaryDays(savedScheduleRows),
subtractBreaksFromWorkHours,
);

const hasIncompleteOrInvalidTimes = unsavedSummaryDays.some(
(day) =>
!TIME_PATTERN.test(day.start_time) || !TIME_PATTERN.test(day.end_time),
Expand Down Expand Up @@ -405,8 +429,12 @@ export function useDailyScheduleEditForm({
state: {
rows: rowsWithHours,
unsavedSummaryDays,
draftWeeklyHours: Number.isNaN(totalWeeklyHours) ? 0 : totalWeeklyHours,
savedWeeklyHours: Number.isNaN(savedWeeklyHours) ? 0 : savedWeeklyHours,
hoursRangeError,
isDirty,
isDirty: hasDifferencesFromCountryDefault,
hasDifferencesFromCountryDefault,
hasUnsavedChanges,
formError,
hasFieldErrors,
getFieldError,
Expand Down
Loading