Skip to content

feat(mobile): support custom measurement entries - #2061

Merged
CodeWithCJ merged 8 commits into
CodeWithCJ:mainfrom
Dragonk:feat/mobile-custom-measurements
Aug 13, 2026
Merged

feat(mobile): support custom measurement entries#2061
CodeWithCJ merged 8 commits into
CodeWithCJ:mainfrom
Dragonk:feat/mobile-custom-measurements

Conversation

@Dragonk

@Dragonk Dragonk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

What problem does this PR solve?
The mobile app lacked support for the backend's existing custom-measurement API. This PR adds manual Daily custom-measurement editing on the Measurements Add screen and manual-only Diary tiles.

How did you implement the solution?

  • Data layer: types, API client, TanStack Query hooks + query keys.
  • MeasurementsAddScreen: one editable manual row per Daily category — numeric, text, boolean (YesNoClearControl), zero, false, comma decimal 1,5; create/save, edit, clear/delete; dirty-state and refetch protection; partial-failure handling; single coherent mutation-pending state for header + footer.
  • Scope cut (maintainer review): Hourly / All / Unlimited removed from mobile — no hour steppers, hour conflicts, multi-entry ordering, or insert-only read-only rows. Backend/web support untouched; those frequencies are simply not exposed here.
  • Health-sync flood fixes: Diary tiles show only entries with source === 'manual' (strict contract — the DB column is NOT NULL DEFAULT 'manual', so null/undefined are not manual); the Add screen filters categories before presentation (frequency === 'Daily' AND name not in the centralized AUTO_HEALTH_SYNC_CUSTOM_CATEGORY_NAMES set, exact/case-sensitive matching mirroring server ingestion). Synced entries never become editable manual state; every save posts source: 'manual'. Renamed former-sync categories become visible again. Garmin raw JSON categories are filtered out.
  • DiaryScreen: manual-only presentation; pull-to-refresh spinner is driven only by the local user-initiated refreshing state (DashboardScreen pattern) — background query invalidations never flash the spinner; Promise.allSettled keeps refresh error-isolated.
  • useUpsertCheckIn: optional showErrorToast: false for the orchestrating screen.
  • Tests: form unit tests, API client, Add screen (incl. 100-category health-heavy integration), Summary (Diary manual-only), Diary refresh isolation + spinner contract.

Linked Issue: Related to #1923 · Related to #1979

How to Test

  1. cd SparkyFitnessMobile && pnpm install
  2. npx jest --runInBand __tests__/utils/customMeasurementsForm.test.ts __tests__/services/customMeasurementsApi.test.ts __tests__/screens/MeasurementsAddScreen.test.tsx __tests__/components/MeasurementsSummary.test.tsx __tests__/screens/DiaryScreen.test.tsx
  3. Build and run on a device: open Measurements Add → confirm only manual Daily categories render (health-sync categories like HRV_SDNN_min, Resting Heart Rate, Garmin Raw Stress Data absent), enter numeric/text/boolean values, save, restart, confirm persisted; Diary shows only manual custom tiles.
  4. Toggle a health integration that creates custom categories → the Add screen no longer floods with pages of fields.
  5. Pull to refresh the Diary: the spinner appears only during the manual pull; swipe-deleting a row (a background refetch) must not flash it.

PR Type

  • Issue (bug fix)
  • New Feature
  • Refactor
  • Documentation

Checklist

All PRs:

  • [MANDATORY - ALL] Integrity & License: I certify this is my own work, free of malicious code, and I agree to the License terms.

New features only:

Mobile changes (SparkyFitnessMobile/):

  • [MANDATORY for Mobile changes] Tested on device or emulator: (Unchecked — the new "More categories" UI has not been device-tested; a fresh APK from the updated HEAD is required before the device pass is marked complete. Automated Jest, tsc, eslint and CI are green.)

Screenshots

Before scope cut

Screenshot_2026-08-08-15-29-24-604_org SparkyApps SparkyFitnessMobile1 dev

After

Screenshot_2026-08-08-15-31-45-537_org SparkyApps SparkyFitnessMobile1 dev ## Notes for Reviewers
  • Server-side has_manual_entries flag on the categories response is the suggested long-term fix; happy to add it in a follow-up PR.

More categories (health-sync names)

Daily-only scope remains. The known health-sync name list remains, but it is now a presentation heuristic, not an exclusion rule: matched Daily categories with no manual entry for the selected date are collapsed under a one-tap More categories section (lightweight Show-more pattern, lazy-rendered), while matched categories that already have a manual entry stay visible in the main custom list. This keeps integration-heavy accounts compact while keeping legitimate collisions (weight, Blood Pressure) accessible. Exact/case-sensitive matching remains; renamed categories remain primary; every manual save uses source: 'manual' and synced-only values are never prefilled. Long-term server-side categorization remains future work.

Summary by CodeRabbit

  • New Features
    • Added support for creating, editing, deleting, and displaying custom daily measurements.
    • Added numeric, text, and yes/no input options, including clearing values.
    • Custom measurements now appear in diary summaries alongside standard measurements.
    • Added loading, validation, retry, deletion, and partial-save handling.
    • Diary refresh now updates custom measurements together with other diary data.
  • Bug Fixes
    • Health-synced measurements are excluded from manual measurement displays.
    • Improved refresh and error-state behavior while offline or when requests fail.
  • Tests
    • Added comprehensive coverage for custom measurements, diary refresh, forms, APIs, and summaries.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The mobile app adds custom measurement APIs, React Query hooks, typed form utilities, editable Daily measurement controls, diary integration, manual-entry filtering, health-sync category handling, and comprehensive tests.

Changes

Custom measurement workflow

Layer / File(s) Summary
Custom measurement contracts and data access
SparkyFitnessMobile/src/types/customMeasurements.ts, SparkyFitnessMobile/src/services/api/measurementsApi.ts, SparkyFitnessMobile/src/hooks/..., SparkyFitnessMobile/__tests__/services/..., SparkyFitnessMobile/__tests__/hooks/...
Adds typed custom measurement contracts, category and entry API operations, query keys, React Query hooks, cache invalidation, mutation error logging, public exports, and service and hook tests.
Custom form reconciliation and operations
SparkyFitnessMobile/src/utils/customMeasurementsForm.ts, SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts
Adds value parsing, manual-source filtering, server synchronization, dirty-row preservation, deletion tombstones, validation, and save/delete operation generation.
Custom measurement editing and persistence
SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx, SparkyFitnessMobile/src/components/YesNoClearControl.tsx, SparkyFitnessMobile/src/utils/autoHealthSyncCategories.ts, SparkyFitnessMobile/src/hooks/useUpsertCheckIn.ts, SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx
Adds Daily custom-category filtering, numeric and boolean controls, category expansion, editing, deletion, retries, partial-save handling, health-sync category classification, optional standard-save toasts, and accessibility and integration tests.
Diary loading and summary display
SparkyFitnessMobile/src/screens/DiaryScreen.tsx, SparkyFitnessMobile/src/components/MeasurementsSummary.tsx, SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx, SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx
Loads date-specific custom measurements, refreshes five diary data sources with settled-error handling, filters to manual entries, and renders formatted custom summary rows with coverage for refresh and display behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MeasurementsAddScreen
  participant useCustomMeasurements
  participant measurementsApi
  participant QueryCache
  User->>MeasurementsAddScreen: edit custom measurement
  MeasurementsAddScreen->>useCustomMeasurements: save or delete entry
  useCustomMeasurements->>measurementsApi: send mutation request
  measurementsApi-->>useCustomMeasurements: return mutation result
  useCustomMeasurements->>QueryCache: invalidate date query and refresh health-sync cache
  QueryCache-->>MeasurementsAddScreen: provide updated custom data
Loading

Possibly related PRs

Suggested reviewers: apedley

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding mobile support for custom measurement entries.
Description check ✅ Passed The description follows the template and covers the problem, implementation, testing steps, screenshots, scope, linked issues, and checklist status.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added enhancement New feature or request mobile labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

PR Validation Results

Change Detection

  • 📱 Mobile changes detected

⚠️ Recommendations (1)

  • Please link a related GitHub issue (Linked Issue: Closes #123).

✅ All required checks passed.

@Dragonk
Dragonk marked this pull request as ready for review August 7, 2026 16:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (10)
SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx (1)

102-112: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Type the mock component props.

Lines 106 and 169 introduce any in new function parameters. Define the minimal prop types instead.

Proposed fix
-    default: ({ title }: any) => (
+    default: ({ title }: { title?: string }) => (
...
-    default: ({ name }: any) => <View testID={`icon-${name}`} />,
+    default: ({ name }: { name: string }) => <View testID={`icon-${name}`} />,

Also applies to: 165-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx` around
lines 102 - 112, Replace the any-typed mock component parameters in the
MeasurementsSummary tests with minimal explicit prop types, covering the
rendered children and any props the mock uses. Update both mock component
definitions around the affected tests while preserving their existing behavior.

Source: Coding guidelines

SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts (1)

843-884: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a Daily tombstone case to this suite.

Both tests in this block use numericCat('Hourly'), so they exercise syncMultiEntry only. No test covers a tombstoned single-entry category, which routes through syncSingleEntry. That gap hides the missing resurrection guard reported on SparkyFitnessMobile/src/utils/customMeasurementsForm.ts lines 224-236.

Add a case that passes numericCat('Daily') with deleted: [{ entryId: 'e1' }] and a server response that still returns e1. Assert that rows is empty and that the tombstone survives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts` around
lines 843 - 884, Add a Daily-category tombstone resurrection test in the
syncCustomForm suite using numericCat('Daily'), a deleted e1 tombstone, and a
server response still containing e1; assert the resulting rows remain empty and
the deleted list retains e1, covering the syncSingleEntry path.
SparkyFitnessMobile/__tests__/hooks/useCustomMeasurements.test.ts (1)

135-188: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding failure-path coverage for both mutations.

Both tests cover the success path only. The onError handlers in useSaveCustomMeasurement and useDeleteCustomMeasurement log through addLog and are never exercised. Add one rejected-promise test per mutation. Assert that mutateAsync rejects and that no invalidation runs for the date key. This protects the partial-failure handling that MeasurementsAddScreen depends on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/hooks/useCustomMeasurements.test.ts` around
lines 135 - 188, Add rejected-promise tests for both useSaveCustomMeasurement
and useDeleteCustomMeasurement. Mock each mutation API to reject, assert
mutateAsync rejects, verify addLog is called through the onError path, and
confirm invalidateQueries is not called for the affected date key.
SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx (5)

648-661: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the exact hour rather than any number.

The test presses hour-plus-new-1 once and then asserts expect.any(Number). That passes for every hour value, including a value the stepper never produced. The stepper starts at the current local hour and increments with wraparound, so the expected value is (new Date().getHours() + 1) % 24. Pin the clock and assert the exact result.

💚 Proposed assertion
   test('hourly category adds a row and saves with the chosen hour', async () => {
     setCustomCategories([customCategory({ id: 'c1', frequency: 'Hourly' })]);
+    jest.useFakeTimers().setSystemTime(new Date(2024, 5, 15, 9, 30));
     const screen = renderScreen();
 
     fireEvent.press(screen.getByTestId('add-custom-c1'));
     fireEvent.press(screen.getByTestId('hour-plus-new-1'));
     fireEvent.changeText(screen.getByTestId('custom-input-new-1'), '10');
     await pressSave(screen);
 
     const payload = savedCustomPayload();
     expect(payload.category_id).toBe('c1');
     expect(payload.value).toBe(10);
-    expect(payload.entry_hour).toEqual(expect.any(Number));
+    expect(payload.entry_hour).toBe(10);
+    jest.useRealTimers();
   });

Confirm that fake timers do not interfere with the await act flush used by pressSave.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 648 - 661, Update the hourly category test around pressSave to pin the
system clock, then assert payload.entry_hour equals (new Date().getHours() + 1)
% 24 after pressing hour-plus-new-1 once. Ensure the clock setup and cleanup do
not interfere with the await act flush performed by pressSave, and restore the
real clock after the test.

444-477: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the duplicated mock setup into a shared helper.

This beforeEach repeats the custom-hook and upsert mock configuration from the first describe block at lines 228-252. The two copies must stay in sync by hand. A shared helper removes the duplication.

♻️ Proposed refactor
+const setupDefaultMocks = () => {
+  mockUseUpsertCheckIn.mockReturnValue({
+    mutate,
+    mutateAsync,
+    isPending: false,
+  } as unknown as ReturnType<typeof useUpsertCheckIn>);
+  setCustomCategories([]);
+  setCustomEntries([]);
+  mockUseSaveCustomMeasurement.mockReturnValue({
+    mutate: jest.fn(),
+    mutateAsync: jest.fn().mockResolvedValue(undefined),
+    isPending: false,
+  } as unknown as ReturnType<typeof useSaveCustomMeasurement>);
+  mockUseDeleteCustomMeasurement.mockReturnValue({
+    mutate: jest.fn(),
+    mutateAsync: jest.fn().mockResolvedValue(undefined),
+    isPending: false,
+  } as unknown as ReturnType<typeof useDeleteCustomMeasurement>);
+};

Then call setupDefaultMocks() from both beforeEach blocks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 444 - 477, Extract the duplicated custom-hook and upsert mock
configuration from the two describe blocks into a shared setupDefaultMocks()
helper. Replace the repeated setup in both beforeEach blocks with calls to
setupDefaultMocks(), while keeping each block’s distinct state and Alert setup
unchanged.

495-537: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two tests do not assert the behavior their names describe.

Line 495: the name states "in API order", but the test only checks that both labels exist. It passes if the screen reverses the order.

Line 523: the name states "prefills boolean true and false distinctly", but the test only counts the Yes and No labels. It never checks which option is selected. The tri-state contract — true selects Yes, false selects No, '' selects nothing — stays untested. That contract is the main reason CustomBooleanControl exists.

💚 Proposed assertions
   test('renders custom categories in API order with literal names and units', () => {
     setCustomCategories([
       customCategory({ id: 'c1', name: 'Stres', display_name: null, measurement_type: 'mmHg' }),
       customCategory({ id: 'c2', name: 'Energy', display_name: 'Energy Level', measurement_type: '' }),
     ]);
     const screen = renderScreen();
 
     expect(screen.getByText('Custom Measurements')).toBeTruthy();
     // User names stay literal — never translated or reordered.
     expect(screen.getByText('Stres (mmHg)')).toBeTruthy();
     expect(screen.getByText('Energy Level')).toBeTruthy();
+    const labels = screen
+      .getAllByText(/Stres \(mmHg\)|Energy Level/)
+      .map((node) => node.props.children.join?.('') ?? node.props.children);
+    expect(labels[0]).toContain('Stres');
   });
@@
     const screen = renderScreen();
 
-    // Both yes/no pairs render; the false entry is a real saved value.
-    expect(screen.getAllByText('Yes')).toHaveLength(2);
-    expect(screen.getAllByText('No')).toHaveLength(2);
+    // c1 holds 'true' -> Yes selected; c2 holds 'false' -> No selected.
+    const yesOptions = screen.getAllByText('Yes');
+    const noOptions = screen.getAllByText('No');
+    expect(yesOptions).toHaveLength(2);
+    expect(noOptions).toHaveLength(2);
+    expect(yesOptions[0].parent?.parent?.props.accessibilityState.selected).toBe(true);
+    expect(noOptions[0].parent?.parent?.props.accessibilityState.selected).toBe(false);
+    expect(yesOptions[1].parent?.parent?.props.accessibilityState.selected).toBe(false);
+    expect(noOptions[1].parent?.parent?.props.accessibilityState.selected).toBe(true);
   });

The parent?.parent traversal matches the pattern already used at line 716. Consider a testID on the option TouchableOpacity in CustomBooleanControl to make these lookups less brittle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 495 - 537, Strengthen the tests named “renders custom categories in API
order with literal names and units” and “prefills boolean true and false
distinctly via tri-state control.” Assert the rendered custom category labels in
their API order rather than only checking presence, and verify the boolean
controls’ selected state so true selects Yes, false selects No, and empty values
select neither. Add stable option testIDs in CustomBooleanControl if needed,
following the existing parent traversal pattern.

119-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the any[] parameters with the custom measurement types.

Lines 119 and 128 declare any[] parameters on two new helper functions. The coding guidelines prohibit any in new code. This PR adds SparkyFitnessMobile/src/types/customMeasurements.ts, which exports the matching contracts, so a typed parameter is available at no cost.

The retrieved learning about as any covers partial hook return values used as focused fixtures. These are parameter annotations on new helpers, so the exemption does not apply.

♻️ Proposed fix
+import type {
+  CustomCategory,
+  CustomMeasurementEntry,
+} from '../../src/types/customMeasurements';
+
-const setCustomCategories = (categories: any[]) => {
+const setCustomCategories = (categories: Partial<CustomCategory>[]) => {
   mockUseCustomCategories.mockReturnValue({
     data: categories,
     isLoading: false,
     isError: false,
     refetch: jest.fn(),
   } as unknown as ReturnType<typeof useCustomCategories>);
 };
 
-const setCustomEntries = (entries: any[]) => {
+const setCustomEntries = (entries: Partial<CustomMeasurementEntry>[]) => {
   mockUseCustomMeasurementsByDate.mockReturnValue({
     data: entries,
     isLoading: false,
     isError: false,
     refetch: jest.fn(),
   } as unknown as ReturnType<typeof useCustomMeasurementsByDate>);
 };

Confirm the exported type names in SparkyFitnessMobile/src/types/customMeasurements.ts before applying.

As per coding guidelines: "Never use any or disable @typescript-eslint/no-explicit-any in new functions or edited code; define explicit TypeScript types or import schemas from @workspace/shared."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 119 - 135, Replace the any[] parameters in setCustomCategories and
setCustomEntries with the corresponding exported custom measurement types from
src/types/customMeasurements.ts. Confirm and import the exact type names,
keeping the existing mock hook return setup unchanged.

Sources: Coding guidelines, Learnings


585-646: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Reuse confirmClearAlert() in both custom-clear tests. This removes duplicated alert-confirmation logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 585 - 646, Extract the repeated alert-button lookup and confirmation act
flow into the existing confirmClearAlert() helper, then call it from both
“clears an existing custom value through delete after confirmation” and
“deleting one category does not affect another category value” tests. Preserve
each test’s existing assertions and behavior.
SparkyFitnessMobile/__tests__/services/customMeasurementsApi.test.ts (1)

98-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Merge the two duplicate POST tests and assert the full body.

Both tests carry the same name and the same assertions. Duplicate test names make Jest failure output ambiguous. Neither test verifies value or entry_date, although the name claims it. Parse the serialized body and assert every field.

♻️ Proposed consolidation
 describe('saveCustomMeasurement', () => {
-    test('sends POST with category_id, value, entry_date', async () => {
+    test('sends POST with category_id, value, entry_date', async () => {
       const savedEntry = {
         id: 'entry-1', category_id: 'cat-1', value: '75', entry_date: '2024-06-15',
       };
       mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(savedEntry) });
 
       const result = await saveCustomMeasurement({
         category_id: 'cat-1',
         value: 75,
         entry_date: '2024-06-15',
       });
 
+      expect(result.id).toBe('entry-1');
       expect(result.value).toBe('75');
-      expect(mockFetch).toHaveBeenCalledWith(
-        'https://example.com/api/measurements/custom-entries',
-        expect.objectContaining({
-          method: 'POST',
-          body: expect.stringContaining('"category_id":"cat-1"'),
-        }),
-      );
-    });
-
-    test('sends POST with category_id, value, entry_date', async () => {
-      mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'e1', category_id: 'cat-1', value: '75', entry_date: '2024-06-15' }) });
-
-      const result = await saveCustomMeasurement({ category_id: 'cat-1', value: 75, entry_date: '2024-06-15' });
-
-      expect(result.id).toBe('e1');
-      expect(mockFetch).toHaveBeenCalledWith(
-        'https://example.com/api/measurements/custom-entries',
-        expect.objectContaining({ method: 'POST', body: expect.stringContaining('"category_id":"cat-1"') }),
-      );
+      const [url, init] = mockFetch.mock.calls[0];
+      expect(url).toBe('https://example.com/api/measurements/custom-entries');
+      expect(init.method).toBe('POST');
+      expect(JSON.parse(init.body)).toMatchObject({
+        category_id: 'cat-1',
+        value: 75,
+        entry_date: '2024-06-15',
+      });
     });
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/services/customMeasurementsApi.test.ts` around
lines 98 - 132, Merge the duplicate tests in the saveCustomMeasurement describe
block into one uniquely named test. Parse the serialized request body passed to
mockFetch and assert category_id, value, and entry_date, while preserving the
result assertions needed for the saved response.
SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx (1)

152-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the ref write out of the render phase.

Line 154 writes customFormRef.current during render. React 19 can discard a render in concurrent mode or double-invoke it in Strict Mode. A render-phase ref write is not idiomatic and can store a value from a render that React throws away. The reads occur in effects and callbacks after commit, so current behavior converges. Consider writing the ref in an effect that is declared before the reconciliation effect, so effect ordering keeps the ref fresh.

♻️ Proposed refactor
   const [customForm, setCustomForm] = useState<CustomFormState>({});
   const customFormRef = useRef<CustomFormState>({});
-  customFormRef.current = customForm;
+  useEffect(() => {
+    customFormRef.current = customForm;
+  }, [customForm]);

Verify that the reconciliation effect still reads the latest committed form after this change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx` around lines 152 -
161, Move the customFormRef.current assignment out of render and into a
useEffect declared before the reconciliation effect, updating it whenever
customForm changes. Ensure reconciliation and callbacks read the latest
committed custom form state while preserving existing dirty-row behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx`:
- Around line 21-24: Update the MeasurementsSummary test fixtures to satisfy the
CheckInMeasurement prop type: include an entry_date value in the empty-object
test, and pass undefined for custom measurements in all three custom-only tests.
Keep the existing assertions and test behavior unchanged.

In `@SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts`:
- Around line 505-514: Remove the timezone-dependent
iso.startsWith('2026-01-05') assertion from the entryTimestampFor test; retain
the local Date getter assertions for year, month, day, and hour, matching the
approach used by the sibling test.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx`:
- Around line 654-669: Update isSaveDisabled in MeasurementsAddScreen so
isCustomDataError no longer disables saving standard measurements; retain
loading and mutation guards. Preserve the custom-section error state and its
retry affordance, allowing standard-only saves while custom data is unavailable.
- Around line 607-622: Update the save flow around the partial-failure handler
to track each operation’s success and clear only dirty keys belonging to
completed operations; preserve unsucceeded form values, dirty fields, and custom
rows so refetch plus a second save retries only pending work without duplicating
successful inserts. Extend the save variant of CustomOp with a rowKey in
customMeasurementsForm.ts, and use it to identify succeeded custom operations.
- Around line 746-812: Add accessible names and button roles to the icon-only
TouchableOpacity controls identified by testIDs hour-minus, hour-plus, and
delete-custom. Include the current category label in each accessibilityLabel,
clearly distinguishing decrease hour, increase hour, and delete actions, with
the delete label indicating its destructive purpose.

In `@SparkyFitnessMobile/src/utils/customMeasurementsForm.ts`:
- Around line 224-236: The single-entry synchronization path must honor
tombstoned server entries like syncMultiEntry. In
SparkyFitnessMobile/src/utils/customMeasurementsForm.ts lines 224-236, update
syncSingleEntry to skip server entries whose id appears in prev.deleted; in
SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts lines
843-884, add a numericCat('Daily') case with tombstone e1 and a server response
containing e1, asserting rows is empty and the tombstone remains.

---

Nitpick comments:
In `@SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx`:
- Around line 102-112: Replace the any-typed mock component parameters in the
MeasurementsSummary tests with minimal explicit prop types, covering the
rendered children and any props the mock uses. Update both mock component
definitions around the affected tests while preserving their existing behavior.

In `@SparkyFitnessMobile/__tests__/hooks/useCustomMeasurements.test.ts`:
- Around line 135-188: Add rejected-promise tests for both
useSaveCustomMeasurement and useDeleteCustomMeasurement. Mock each mutation API
to reject, assert mutateAsync rejects, verify addLog is called through the
onError path, and confirm invalidateQueries is not called for the affected date
key.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx`:
- Around line 648-661: Update the hourly category test around pressSave to pin
the system clock, then assert payload.entry_hour equals (new Date().getHours() +
1) % 24 after pressing hour-plus-new-1 once. Ensure the clock setup and cleanup
do not interfere with the await act flush performed by pressSave, and restore
the real clock after the test.
- Around line 444-477: Extract the duplicated custom-hook and upsert mock
configuration from the two describe blocks into a shared setupDefaultMocks()
helper. Replace the repeated setup in both beforeEach blocks with calls to
setupDefaultMocks(), while keeping each block’s distinct state and Alert setup
unchanged.
- Around line 495-537: Strengthen the tests named “renders custom categories in
API order with literal names and units” and “prefills boolean true and false
distinctly via tri-state control.” Assert the rendered custom category labels in
their API order rather than only checking presence, and verify the boolean
controls’ selected state so true selects Yes, false selects No, and empty values
select neither. Add stable option testIDs in CustomBooleanControl if needed,
following the existing parent traversal pattern.
- Around line 119-135: Replace the any[] parameters in setCustomCategories and
setCustomEntries with the corresponding exported custom measurement types from
src/types/customMeasurements.ts. Confirm and import the exact type names,
keeping the existing mock hook return setup unchanged.
- Around line 585-646: Extract the repeated alert-button lookup and confirmation
act flow into the existing confirmClearAlert() helper, then call it from both
“clears an existing custom value through delete after confirmation” and
“deleting one category does not affect another category value” tests. Preserve
each test’s existing assertions and behavior.

In `@SparkyFitnessMobile/__tests__/services/customMeasurementsApi.test.ts`:
- Around line 98-132: Merge the duplicate tests in the saveCustomMeasurement
describe block into one uniquely named test. Parse the serialized request body
passed to mockFetch and assert category_id, value, and entry_date, while
preserving the result assertions needed for the saved response.

In `@SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts`:
- Around line 843-884: Add a Daily-category tombstone resurrection test in the
syncCustomForm suite using numericCat('Daily'), a deleted e1 tombstone, and a
server response still containing e1; assert the resulting rows remain empty and
the deleted list retains e1, covering the syncSingleEntry path.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx`:
- Around line 152-161: Move the customFormRef.current assignment out of render
and into a useEffect declared before the reconciliation effect, updating it
whenever customForm changes. Ensure reconciliation and callbacks read the latest
committed custom form state while preserving existing dirty-row behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54d5ca67-ee64-46a0-853e-96248f055613

📥 Commits

Reviewing files that changed from the base of the PR and between cb2c3cb and 21e6374.

📒 Files selected for processing (21)
  • SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx
  • SparkyFitnessMobile/__tests__/hooks/useCustomMeasurements.test.ts
  • SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx
  • SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx
  • SparkyFitnessMobile/__tests__/services/customMeasurementsApi.test.ts
  • SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts
  • SparkyFitnessMobile/src/components/CustomBooleanControl.tsx
  • SparkyFitnessMobile/src/components/MeasurementsSummary.tsx
  • SparkyFitnessMobile/src/hooks/index.ts
  • SparkyFitnessMobile/src/hooks/queryKeys.ts
  • SparkyFitnessMobile/src/hooks/useCustomMeasurements.ts
  • SparkyFitnessMobile/src/hooks/useCustomNutrients.ts
  • SparkyFitnessMobile/src/hooks/useDailySummary.ts
  • SparkyFitnessMobile/src/hooks/useMeasurements.ts
  • SparkyFitnessMobile/src/hooks/useNutrientDisplayPreferences.ts
  • SparkyFitnessMobile/src/hooks/useUpsertCheckIn.ts
  • SparkyFitnessMobile/src/screens/DiaryScreen.tsx
  • SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
  • SparkyFitnessMobile/src/services/api/measurementsApi.ts
  • SparkyFitnessMobile/src/types/customMeasurements.ts
  • SparkyFitnessMobile/src/utils/customMeasurementsForm.ts

Comment thread SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts Outdated
Comment thread SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
Comment thread SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
Comment thread SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx Outdated
Comment thread SparkyFitnessMobile/src/utils/customMeasurementsForm.ts Outdated
Correction round for the custom measurements PR:

- Partial save failures no longer discard unsaved input. Each CustomOp
  carries its local rowKey; operations that reach the server are removed
  from the pending/dirty set, failed and not-yet-attempted rows stay
  dirty so the refetch preserves typed values and a retry submits only
  the remaining work. Successful All/Unlimited inserts are never
  re-sent, so retries cannot duplicate them. Standard fields keep their
  dirty markers until the upsert actually persists.
- isSaveDisabled no longer includes isCustomDataError: a custom
  categories/entries fetch failure no longer blocks saving weight,
  steps, or other standard measurements (retry affordance kept).
- syncSingleEntry now honors tombstones like syncMultiEntry: a Daily id
  deleted locally is never resurrected by a stale server response, and
  the marker survives until the server stops returning the id.
- Icon-only row controls (hour minus/plus, delete) get accessible names
  and roles using the category label.
- Move customFormRef.current out of the render body into an effect.
- Test fixture fixes: MeasurementsSummary uses valid CheckInMeasurement
  shapes; the timestamp assertion is timezone-independent (local
  getters only); buildCustomOps expectations now pin rowKey for a
  complete operation body; hourly save asserts the exact chosen hour
  via faked system time; boolean tri-state selection and API render
  order are asserted explicitly.

Assisted-by: Open WebUI

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx (2)

1090-1096: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the footer busy state with the header busy state.

The header uses isSaving, which covers all three mutations. FooterSaveBar uses upsertMutation.isPending only. During a custom-only save the footer button is disabled but shows no busy label.

♻️ Proposed change
         <FooterSaveBar
           onPress={handleSave}
           disabled={isSaveDisabled}
-          busy={upsertMutation.isPending}
+          busy={isSaving}
         />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx` around lines 1090
- 1096, Update the FooterSaveBar in the MeasurementsAddScreen render to use the
same isSaving state as the header for its busy prop, ensuring saves handled by
all three mutations display the busy label consistently.

589-651: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

standardPersisted can never be true at line 649.

upsertMutation.mutateAsync runs only when customSucceeded is true. If it resolves, customSucceeded stays true and the block at lines 632-636 returns. If it rejects, standardPersisted stays false. The branch at lines 649-651 is therefore unreachable, and the standardPersisted variable is dead state.

Remove the variable and the branch, or restructure the flow so a successful standard save clears dirtyFieldsRef even when a later step fails.

♻️ Proposed simplification
       const remainingDirtyCustom = new Set(dirtyCustomKeysRef.current);
-      let standardPersisted = false;
       let customSucceeded = true;
@@
         if (customSucceeded && hasAnyField) {
           try {
             await upsertMutation.mutateAsync(payload);
-            standardPersisted = true;
           } catch {
             customSucceeded = false;
           }
         }
@@
       dirtyCustomKeysRef.current = remainingDirtyCustom;
-      if (standardPersisted) {
-        dirtyFieldsRef.current = new Set();
-      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx` around lines 589 -
651, Remove the unused standardPersisted state from the save flow around the
custom operation loop and upsertMutation call. Eliminate its assignment and the
trailing dirtyFieldsRef clearing branch, preserving the existing behavior where
the function returns immediately after a fully successful standard save and
retains standard dirty markers on partial failure.
SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx (2)

739-777: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a partial-failure case that includes a delete operation.

The suite covers save operations only. The screen also issues delete operations from catForm.deleted. A case where a delete succeeds and a later save fails would show whether the retry re-sends the same delete. That path is the one flagged in SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx at lines 593-621.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 739 - 777, Add a test alongside the existing partial custom failure case
that populates catForm.deleted, makes the delete operation succeed, then makes a
later custom save fail. Retry the save and assert the successful delete is
invoked only once while the failed save is retried, unsaved rows remain pending
after the first attempt, and navigation completes after retry.

546-556: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer a role/testID query over .parent?.parent traversal.

The assertions walk two levels up from the text node to reach the touchable. A markup change inside CustomBooleanControl breaks these four assertions without any behavior change. Query the option by accessibilityRole and accessible name, or add a testID per option in CustomBooleanControl.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx` around
lines 546 - 556, Replace the four `.parent?.parent` traversals in the tri-state
assertions with stable role-and-accessible-name queries for each Yes/No option,
or use dedicated per-option testIDs exposed by CustomBooleanControl. Keep the
existing selected-state expectations for both categories unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx`:
- Around line 593-621: The custom deletion lifecycle is incomplete: successful
deletes must clear their tombstones and remain protected during reconciliation.
In MeasurementsAddScreen.tsx lines 593-621, update the delete-success branch
alongside remainingDirtyCustom.delete to remove the matching entryId tombstone
from customForm; in lines 397-412, ensure syncCustomForm consults
current.deleted during reconciliation, or mark the deleted row key dirty there
so refetches cannot restore it. Use the existing custom form and syncCustomForm
state paths, and preserve pending tombstones and dirty entries when deletion
fails.

---

Nitpick comments:
In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx`:
- Around line 739-777: Add a test alongside the existing partial custom failure
case that populates catForm.deleted, makes the delete operation succeed, then
makes a later custom save fail. Retry the save and assert the successful delete
is invoked only once while the failed save is retried, unsaved rows remain
pending after the first attempt, and navigation completes after retry.
- Around line 546-556: Replace the four `.parent?.parent` traversals in the
tri-state assertions with stable role-and-accessible-name queries for each
Yes/No option, or use dedicated per-option testIDs exposed by
CustomBooleanControl. Keep the existing selected-state expectations for both
categories unchanged.

In `@SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx`:
- Around line 1090-1096: Update the FooterSaveBar in the MeasurementsAddScreen
render to use the same isSaving state as the header for its busy prop, ensuring
saves handled by all three mutations display the busy label consistently.
- Around line 589-651: Remove the unused standardPersisted state from the save
flow around the custom operation loop and upsertMutation call. Eliminate its
assignment and the trailing dirtyFieldsRef clearing branch, preserving the
existing behavior where the function returns immediately after a fully
successful standard save and retains standard dirty markers on partial failure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a001d94-d7bf-4e11-949d-eb595162ecba

📥 Commits

Reviewing files that changed from the base of the PR and between 21e6374 and 520e2ba.

📒 Files selected for processing (5)
  • SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx
  • SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx
  • SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts
  • SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
  • SparkyFitnessMobile/src/utils/customMeasurementsForm.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • SparkyFitnessMobile/tests/utils/customMeasurementsForm.test.ts
  • SparkyFitnessMobile/tests/components/MeasurementsSummary.test.tsx
  • SparkyFitnessMobile/src/utils/customMeasurementsForm.ts

Comment thread SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
CodeRabbit follow-up on the custom measurements partial-failure fix: a
delete that reached the server must be removed from the pending set AND
its tombstone dropped from the custom form, otherwise a retry after a
later partial failure re-sends the DELETE for an already-removed entry
id. Reconciliation already honors tombstones (multi-entry guard plus the
new single-entry guard), so dropping the marker after a confirmed delete
completes the lifecycle. Regression test: delete + failing save ->
retry sends the save but not the delete.

Assisted-by: Open WebUI

@apedley apedley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a scope cut here:

  • Every auto created category is daily. Habits are daily. And hand made categories are mostly daily. So the hourly/all/unlimited stuff (hour steppers, hour conflict slot sets,insert only read only rows, and most of the tombstone code) will see next to no traffic.

Drop the hourly/all/unlimited stuff. If you want to pull it out into a separate PR that's fine but it needs to be more of a feature than something tacked on to the daily log.

  • Anyone syncing health data gets 5 pages of fields on the add measurements screen. Any synced metric without a dedicated handler gets stored as a custom measurement. But it gets worse: editing one preserves the source so the manually entered value is overwritten next sync. Garmin users with mood data will even get a raw JSON blob displayed.

Filter everything:

  1. Diary tiles should only show when the source === 'manual'
  2. On the add screen, hide categories whose names matches the known health sync list. Exact name matching only hurts someone who literally typed "HRV_SDNN_min" as a category name, and their category is already receiving synced entries anyway since ingestion matches by name - renaming it un-hides it. The real fix is server side (something like a has_manual_entries flag on the categories response) so if you'd like to also add that here or in another PR feel free

--

Are you testing yourself or having AI do it? Some of this stuff might have been caught if it wasn't tested in isolation. Especially the add screen flood.

refetchNutrientPrefs,
]);

const isRefreshing =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ORs from 5 queries so any cache invalidation while diary is up (swipe deleting rows for example) flashes the pull to refresh spinner. Check how dashboard screen does it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Diary pull-to-refresh now uses Promise.allSettled over the five queries: a failing custom refetch no longer prevents the remaining refetches from completing nor throws from the refresh handler, and the spinner still tears down in finally. Regression test added (Test G) verifying one rejected custom refetch -> other refetches still run, no throw, refreshing returns to false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — my previous reply addressed failure isolation, not the spinner behavior you were pointing out. The RefreshControl is now driven only by the local user-initiated refreshing state, matching DashboardScreen; background query invalidations/refetches no longer show the pull-to-refresh spinner. I kept Promise.allSettled only for error isolation inside an actual manual refresh and added regression coverage for both cases:

  • Test E: all five queries reporting background isRefetching (cache invalidation while the diary is up) → RefreshControl.props.refreshing === false.
  • Test E2: an actual pull shows refreshing === true while the queries are pending and false after settlement.
  • Test G: one rejected refetch still lets the others run and never throws; spinner returns to false.

I also removed the PR1-added isRefetching passthroughs from useDailySummary, useMeasurements, useCustomNutrients and useNutrientDisplayPreferences — they existed only to build the aggregate spinner and have no other consumers — so the shared hook APIs stay minimal.

text1: 'Save failed',
text2: 'Could not save measurements. Please try again.',
});
// Standalone callers (e.g. VitalsCard) rely on this toast. Multi-mutation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VitalsCard is not a thing on mobile. I think it may have been in the past

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed the stale 'VitalsCard' reference from the useUpsertCheckIn onError comment — the comment now just says standalone callers rely on the toast while multi-mutation flows pass showErrorToast:false.

labels: { yes: string; no: string; clear: string };
}

const CustomBooleanControl: React.FC<CustomBooleanControlProps> = ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really a nullable boolean picker. A name like YesNoClearControl saves the next reader the "why does a boolean have three states" question.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. CustomBooleanControl renamed to YesNoClearControl (file, component, and the import in MeasurementsAddScreen). It is a nullable/clearable boolean picker, so the name now answers the 'why three states' question.

Comment thread SparkyFitnessMobile/src/hooks/index.ts Outdated
export { useFastingTimer } from './useFastingTimer';
export type { FastTimerValues } from './useFastingTimer';
export { useCustomCategories, useCustomMeasurementsByDate, useSaveCustomMeasurement, useDeleteCustomMeasurement } from './useCustomMeasurements';
export { customCategoriesQueryKey, customMeasurementsByDateQueryKey } from './queryKeys';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add it to the existing import for this file

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. customCategoriesQueryKey and customMeasurementsByDateQueryKey are now merged into the existing ./queryKeys import block instead of a standalone re-export line.

Maintainer (apedley) CHANGES_REQUESTED scope cut and health-sync fixes:

Scope cut — remove Hourly / All / Unlimited from the mobile daily-log:
- Drop hour steppers, hour conflict detection, multi-entry ordering,
  insert-only read-only rows, entryTimestampFor, and multi-entry tombstone
  machinery. customMeasurementsForm is now a small Daily-specific model
  (one editable manual row per category) and the add screen exposes only
  Daily frequency. Backend/web support untouched.

Health-sync flood fixes:
- Diary tiles (MeasurementsSummary) show ONLY manual custom entries
  (source === 'manual' or legacy null); synced entries never render.
- Add screen filters categories BEFORE presentation: eligible =
  frequency 'Daily' AND name not in the centralized
  AUTO_HEALTH_SYNC_CUSTOM_CATEGORY_NAMES set (exact, case-sensitive —
  mirrors server cat.name === categoryName). The set is derived from
  DEFAULT_UNITS_BY_HEALTH_TYPE keys (incl. _min/_max/_avg variants),
  the Garmin 'Raw Stress Data' JSON category, and Oura/Fitbit/Polar/
  Withings/Google hard-coded categoryNames.
- Synced entries never become editable manual state (no prefill, no
  preserved synced source); every save posts source 'manual'.
- A renamed former-sync category becomes visible again.
- Garmin raw JSON category is filtered out (no special parsing needed).
- Integrated regression: 100-category health-heavy account does not
  flood the add screen; manual + renamed categories still editable.

Reliability:
- Diary pull-to-refresh uses Promise.allSettled (one failing refetch no
  longer blocks the others nor throws); spinner still tears down.
- Retained prior guarantees: custom fetch failure does not block standard
  saves, partial save failure preserves input, retries skip succeeded
  rows, zero/false/comma-decimal remain valid, dirty values survive
  refetch, one coherent mutation-pending state for header + footer.

Inline maintainer nits:
- CustomBooleanControl renamed to YesNoClearControl.
- hooks/index.ts merges the custom query keys into the existing
  queryKeys import block.
- Removed the stale VitalsCard reference in useUpsertCheckIn comment.
- Consolidated the duplicate customMeasurementsApi POST tests into one
  asserting the complete request body.

Tests: Daily types (numeric/text/boolean/zero/false/comma), visibility
filters, synced-source handling, Diary manual-only, health-heavy
integration, dirty/refetch/partial-failure reliability, and the Diary
allSettled refresh. Hourly/All/Unlimited unit tests removed.

Assisted-by: Open WebUI

@Dragonk Dragonk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review — all points addressed in 37e4821a.

Scope cut (Hourly / All / Unlimited removed):

  • Dropped hour steppers, hour-conflict detection, multi-entry ordering, insert-only read-only rows, entryTimestampFor, and the multi-entry tombstone machinery. customMeasurementsForm is now a small Daily-specific model (one editable manual row per category) and the add screen exposes only Daily frequency. Backend/web support untouched; those frequencies are simply not surfaced as editable fields here.

Health-sync flood fixed (both filters):

  1. Diary tiles (MeasurementsSummary) now show only entries with source === 'manual' (legacy null treated as manual). Synced entries never render as tiles.
  2. Add screen filters before presentation: eligible = frequency === 'Daily' AND name not in a centralized AUTO_HEALTH_SYNC_CUSTOM_CATEGORY_NAMES set. Matching is exact + case-sensitive, mirroring server cat.name === categoryName. The set was derived by auditing the repository: DEFAULT_UNITS_BY_HEALTH_TYPE keys (incl. _min/_max/_avg variants), the Garmin Raw Stress Data JSON category, and the hard-coded categoryName values in Oura/Fitbit/Polar/Withings/Google processors. Renaming a category away from the canonical name re-shows it (verified by test).
  3. Synced entries are never prefilled/edited as manual state and a synced source is never preserved into a manual save — every save from this screen posts source: 'manual'. Garmin raw JSON categories are filtered out (no UI parsing needed). The 5-pages-of-fields flood is covered by a 100-category integration test.

Testing honesty: all validation here is automated (Jest, tsc, lint, CI).
Then when everythink is green I built Android apk and test this on real device.

Also fixed the four inline comments (Diary allSettled refresh, VitalsCard comment, YesNoClearControl rename, hooks/index query-keys import).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx`:
- Around line 238-274: Update the “manual entry with value 0 still appears” and
“manual boolean false still appears” tests to assert the rendered value text in
addition to the tile labels. Verify the UI displays 0 for the numeric entry and
false for the boolean entry, so formatting cannot silently drop either value.

In `@SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx`:
- Around line 930-935: Correct the fixture-count comment above healthNames to
match the array’s actual auto-generated category count. In the integrated
health-heavy account test, replace the fixed custom-input-health-98 and
custom-input-health-99 checks with an index-independent assertion that no input
whose identifier matches the health-* pattern is rendered.

In `@SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts`:
- Around line 13-19: Remove the undeclared measurement_type property from the
dailyCat fixture while preserving all fields required by CustomCategoryMeta and
its existing dataType behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a006b920-de6c-4149-a806-c96a05c771a3

📥 Commits

Reviewing files that changed from the base of the PR and between 9c50714 and 37e4821.

📒 Files selected for processing (13)
  • SparkyFitnessMobile/__tests__/components/MeasurementsSummary.test.tsx
  • SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx
  • SparkyFitnessMobile/__tests__/screens/MeasurementsAddScreen.test.tsx
  • SparkyFitnessMobile/__tests__/services/customMeasurementsApi.test.ts
  • SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts
  • SparkyFitnessMobile/src/components/MeasurementsSummary.tsx
  • SparkyFitnessMobile/src/components/YesNoClearControl.tsx
  • SparkyFitnessMobile/src/hooks/index.ts
  • SparkyFitnessMobile/src/hooks/useUpsertCheckIn.ts
  • SparkyFitnessMobile/src/screens/DiaryScreen.tsx
  • SparkyFitnessMobile/src/screens/MeasurementsAddScreen.tsx
  • SparkyFitnessMobile/src/utils/autoHealthSyncCategories.ts
  • SparkyFitnessMobile/src/utils/customMeasurementsForm.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • SparkyFitnessMobile/src/hooks/useUpsertCheckIn.ts
  • SparkyFitnessMobile/src/hooks/index.ts
  • SparkyFitnessMobile/tests/screens/DiaryScreen.test.tsx
  • SparkyFitnessMobile/tests/services/customMeasurementsApi.test.ts
  • SparkyFitnessMobile/src/screens/DiaryScreen.tsx

Comment thread SparkyFitnessMobile/__tests__/utils/customMeasurementsForm.test.ts
@github-actions github-actions Bot added the bug Something isn't working label Aug 8, 2026
Dragonk added 2 commits August 8, 2026 10:23
- MeasurementsSummary: the 'value 0' and 'boolean false' Diary tests now
  also assert the rendered value text ('0', 'false') so formatting cannot
  silently drop either real value.
- MeasurementsAddScreen integrated health-heavy test: corrected the
  fixture-count comment (100+) and replaced fixed index checks with an
  index-independent assertion that no input whose testID matches the
  health-* pattern renders.
- customMeasurementsForm: removed the undeclared measurement_type field
  from the dailyCat fixture (kept every field required by
  CustomCategoryMeta).

Assisted-by: Open WebUI
Follow-up to the maintainer's CHANGES_REQUESTED review:

Diary pull-to-refresh spinner:
- The RefreshControl is now driven ONLY by the local user-initiated
  'refreshing' state, matching DashboardScreen. Background React Query
  refetches/cache invalidations (e.g. swipe-deleting a row) no longer
  turn on the pull-to-refresh spinner.
- Removed the PR1-added isRefetching passthroughs from useDailySummary,
  useMeasurements, useCustomNutrients and useNutrientDisplayPreferences
  (they existed only to build the aggregate spinner and have no other
  consumers). DiaryScreen no longer destructures them.
- Kept Promise.allSettled inside the manual onRefresh for error
  isolation; one failing refetch neither blocks the others nor throws.
- Tests: Test E asserts all five queries reporting background
  isRefetching keep the spinner off; Test E2 asserts a manual pull
  shows the spinner while pending and clears after settlement; Test G
  retains rejected-query isolation.

Strict manual source contract:
- isManualSource now returns source === 'manual' ONLY. The DB column is
  source VARCHAR(50) NOT NULL DEFAULT 'manual', so null/undefined are
  NOT manual; removed the 'legacy null' exception everywhere.
- Diary hasAnyMeasurement/manualCustomMeasurements, MeasurementsSummary
  (defense-in-depth) and syncCustomForm all use the single isManualSource
  predicate; no inline source filters remain.
- Tests: null and undefined sources never prefill the Daily manual
  editor and never create a Diary tile; synced (garmin/healthkit)
  sources stay excluded; a user-entered value still saves a fresh
  operation with source 'manual'.

Cleanup:
- Removed the dead standardPersisted state (the only true assignment
  always preceded the full-success return; the later branch was
  unreachable). Partial-failure behavior is unchanged.

Assisted-by: Open WebUI
@Dragonk

Dragonk commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Need a scope cut here:

  • Every auto created category is daily. Habits are daily. And hand made categories are mostly daily. So the hourly/all/unlimited stuff (hour steppers, hour conflict slot sets,insert only read only rows, and most of the tombstone code) will see next to no traffic.

Drop the hourly/all/unlimited stuff. If you want to pull it out into a separate PR that's fine but it needs to be more of a feature than something tacked on to the daily log.

  • Anyone syncing health data gets 5 pages of fields on the add measurements screen. Any synced metric without a dedicated handler gets stored as a custom measurement. But it gets worse: editing one preserves the source so the manually entered value is overwritten next sync. Garmin users with mood data will even get a raw JSON blob displayed.

Filter everything:

  1. Diary tiles should only show when the source === 'manual'
  2. On the add screen, hide categories whose names matches the known health sync list. Exact name matching only hurts someone who literally typed "HRV_SDNN_min" as a category name, and their category is already receiving synced entries anyway since ingestion matches by name - renaming it un-hides it. The real fix is server side (something like a has_manual_entries flag on the categories response) so if you'd like to also add that here or in another PR feel free

--

Are you testing yourself or having AI do it? Some of this stuff might have been caught if it wasn't tested in isolation. Especially the add screen flood.

I built release apk and test in on my android phone. I didn't add health sync in this testing app and that was my mistake.
After changes I will built another apk and test it again. After that I will check on PR description that PR was tested on real phone or go with another fixes

Edit. Tested. I attach screenshots to PR description.

@Dragonk
Dragonk requested a review from apedley August 8, 2026 13:35
@apedley

apedley commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Everything looks really good which makes me sorry I have to walk back part of this.. I had assumed the collision space was hard to type names like HRV_SDNN_min. Something like "weight" or "Blood Pressure" we can't filter out.

Let's do this: Keep the name list, but matched categories with no manual entry collapse into a "More categories" section behind one tap instead of disappearing. That way it can ship and I can figure out a long term solution for server side.

Maintainer apedley requirement (5228882952): the exact health-sync name
list must stop being an exclusion rule. Matched Daily categories with no
manual entry for the selected date now collapse under a one-tap 'More
categories' section instead of disappearing; matched categories that
already have a manual entry stay in the main custom list.

Architecture (form model vs presentation):
- FORM MODEL is now dailyCustomCategories = every frequency==='Daily'
  category (health-sync names included), feeding syncCustomForm,
  buildCustomOps, delete-confirmation lookup, reconciliation, and dirty
  preservation. A value typed inside the collapsed section is always part
  of the form regardless of expansion state or refetch.
- manualCategoryIds is built from customMeasurements via the strict
  isManualSource helper (synced/null/undefined never count) for the
  CURRENT selected date only (temporary mobile heuristic; server-side
  has_manual_entries remains future work per maintainer).
- PRESENTATION partitions dailyCustomCategories: primary = not a known
  health-sync name OR has a manual entry today; more = known health-sync
  name AND no manual entry today. Both preserve API ordering.
- 'More categories' uses the existing FoodForm 'Show more nutrients'
  pattern (Button variant=ghost, self-start, py-0 px-0, accent text,
  lazy render via showMoreCategories && map). accessibilityRole=button,
  accessibilityState={{ expanded }}, label 'More categories'.

Behavior preserved: manual-only saves (source 'manual'), synced values
never prefilled, synced entries never deleted by manual saves, Hourly /
All / Unlimited absent everywhere, dirty values survive collapse/re-expand
and background refetch, partial-failure retry and tombstone lifecycle
unchanged, Diary strict manual-only contract unchanged.

Tests: updated the old 'hides health-sync categories' expectation and the
health-heavy account test; added A-G + K-L regressions (collapsed
weight, manual Blood Pressure promoted+prefilled, synced-only behind
More not prefilled, null-source stays hidden, save-from-More uses manual
source, dirty survives collapse/re-expand, dirty survives refetch, no
toggle when nothing hidden, accessibility expanded state).

Full Jest 286 suites / 4817 tests, 0 failed.

Assisted-by: Open WebUI
@Dragonk

Dragonk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Fresh review requested for d257afcb — implements maintainer apedley's 'More categories' requirement: the health-sync exact-name list is now a presentation heuristic, not an exclusion rule. Form model uses all Daily categories (dailyCustomCategories) so values typed inside the collapsed section always save; manualCategoryIds (strict isManualSource, selected date) partitions presentation into primary vs one-tap 'More categories' (FoodForm Show-more pattern, lazy render, accessibilityRole button + expanded state). Synced values never prefilled, manual saves keep source 'manual', Hourly/All/Unlimited stay absent, Diary manual-only contract unchanged.

@Dragonk

Dragonk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — that makes sense. I kept the exact health-sync name list, but changed it from an exclusion rule into a presentation heuristic: matched Daily categories with no manual entry for the selected date now stay accessible under a one-tap More categories section, while matched categories that already have a manual entry remain in the main list.

This keeps health-heavy accounts compact without hiding legitimate collisions such as weight or Blood Pressure. The form/save model still includes all Daily categories, every manual save uses source: 'manual', and synced-only values are never prefilled as editable manual state.

I also kept the UI intentionally close to the existing Measurements form — More categories follows the app’s lightweight Show more… pattern rather than introducing a separate card or screen.

I’ll build and test a fresh APK from this updated HEAD before treating the device validation/screenshots as final.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@Dragonk I will review the updated implementation for the requested scope.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Dragonk

Dragonk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@apedley I attach new screenshots here. This is what you had in mind with sync categories?

Screenshots

Screenshot_2026-08-09-23-45-40-037_org SparkyApps SparkyFitnessMobile1 dev Screenshot_2026-08-09-23-45-52-650_org SparkyApps SparkyFitnessMobile1 dev Screenshot_2026-08-09-23-47-50-299_org SparkyApps SparkyFitnessMobile1 dev

@CodeWithCJ
CodeWithCJ merged commit 43c6b4e into CodeWithCJ:main Aug 13, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request mobile

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants