Skip to content

fix: TestFlight feedback — subtitle color, AI retry, weight accuracy#416

Merged
hessius merged 5 commits intoversion/2.4.0from
fix/testflight-feedback
May 4, 2026
Merged

fix: TestFlight feedback — subtitle color, AI retry, weight accuracy#416
hessius merged 5 commits intoversion/2.4.0from
fix/testflight-feedback

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented May 3, 2026

TestFlight Feedback Fixes

1. Profile subtitle too faint (ControlCenter)

Changed text-muted-foregroundtext-foreground/70 on the active profile author subtitle in the home screen control center. Now clearly readable while remaining visually secondary.

2. AI shot analysis 503 errors — retry + messaging

  • Added retryWithBackoff helper (2 retries, exponential delay 2s→4s)
  • Wraps both Gemini generateContent calls (BrowserAIService + DirectModeInterceptor)
  • Parses actual error message for user-friendly display instead of generic failures
  • Added SERVICE_UNAVAILABLE error code to AIServiceError type

3. Shot weight assessment inaccuracy

Root cause: The AI prompt computed final_weight_g by iterating telemetry points but skipping those with status 'retracting'. This meant it saw the weight at the stop signal (~34g) rather than the actual settled weight (~36g) that includes post-retraction drip.

Fix:

  • Read actual final weight from the absolute last data point (same as getHistoryMetrics() used by the static analysis UI)
  • Added explicit prompt guidance explaining Meticulous stop-signal behavior so the AI doesn't penalize normal weight deviation (±5% tolerance)

Testing

  • ✅ 981/981 tests pass
  • ✅ 0 lint errors
  • ✅ TypeScript clean (only pre-existing test setup warning)

1. ControlCenter: brighten profile author subtitle (text-foreground/70)
2. AI analysis: add retry with exponential backoff on transient errors
   (503, UNAVAILABLE, overloaded, RESOURCE_EXHAUSTED) — max 2 retries.
   Parse actual error for user-friendly messaging.
3. Shot weight: use actual settled weight (including post-retraction drip)
   instead of weight-at-stop-signal. Adds prompt guidance about Meticulous
   stop-signal behavior so AI doesn't penalize normal weight deviation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses TestFlight feedback in the web app by refining control-center subtitle contrast and adjusting direct-mode AI shot analysis behavior around transient Gemini failures and settled shot weight interpretation. It fits into the existing direct-mode/browser AI stack by changing both the interceptor-side LLM request path and the browser-side Gemini service error handling.

Changes:

  • Updates the Control Center active-profile author subtitle styling to improve readability.
  • Adjusts direct-mode LLM shot analysis to use the last telemetry point’s settled weight and adds prompt guidance about Meticulous stop/retraction behavior.
  • Adds transient Gemini retry/error handling in both the direct-mode interceptor path and BrowserAIService.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
apps/web/src/services/interceptor/DirectModeInterceptor.ts Updates direct-mode LLM shot analysis metrics/prompting and adds inline Gemini retry/error handling.
apps/web/src/services/ai/BrowserAIService.ts Extends browser-side AI error typing and adds a retry helper for Gemini shot-analysis calls.
apps/web/src/components/ControlCenter.tsx Tweaks subtitle text color for the active profile author line.

Comment on lines +2835 to +2874
const isRetryable = (err: unknown): boolean => {
const m = err instanceof Error ? err.message : String(err)
return m.includes('503') || m.includes('UNAVAILABLE') || m.includes('overloaded') || m.includes('RESOURCE_EXHAUSTED') || m.includes('429')
}

let response
let lastErr: unknown
for (let attempt = 0; attempt <= 2; attempt++) {
try {
response = await client.models.generateContent({
model: modelId,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})
break
} catch (err) {
lastErr = err
if (attempt < 2 && isRetryable(err)) {
await new Promise(r => setTimeout(r, 2000 * Math.pow(2, attempt)))
continue
}
throw err
}
}
if (!response) throw lastErr

const analysisText = response.text ?? ''
return jsonResponse({
status: 'success',
llm_analysis: analysisText,
cached: false,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Analysis failed'
const raw = err instanceof Error ? err.message : String(err)
let msg: string
if (raw.includes('503') || raw.includes('UNAVAILABLE') || raw.includes('overloaded'))
msg = 'The AI model is temporarily unavailable. Please try again in a moment.'
else if (raw.includes('429') || raw.includes('RESOURCE_EXHAUSTED') || raw.includes('quota'))
msg = 'API quota exceeded. Please wait a moment and try again.'
else
msg = raw || 'Analysis failed'
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — extracted shared retryUtils.ts with isRetryableError, retryWithBackoff, and formatGeminiError. Both BrowserAIService and DirectModeInterceptor now import from the single source. 19 regression tests added.

Comment thread apps/web/src/services/interceptor/DirectModeInterceptor.ts Outdated
Comment on lines +2732 to +2735
IMPORTANT: The 'final_weight_g' in shot_summary is the actual settled weight AFTER the machine's piston retraction completes.
The Meticulous machine issues a stop signal BEFORE the target weight is reached, accounting for residual flow that will drip
into the cup during piston retraction. This means the final weight accurately reflects the total liquid in the cup.
Do NOT penalize the shot for weight deviation unless 'weight_deviation_pct' exceeds ±5%.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — when weight increases during retraction, a synthetic 'Drip (post-retraction)' stage is appended with the drip weight and duration. This closes the gap between the last active stage's cumulative_weight_at_end and final_weight_g.

Comment on lines +2647 to +2657
// Use the actual settled weight (including drip during piston retraction)
// same logic as getHistoryMetrics() — the absolute last data point has the true final weight
const lastRawPt = pts[pts.length - 1]
const actualFinalWeight = safeNumber((lastRawPt as Record<string, Record<string, unknown>>)?.shot?.weight) || finalWeight

const localAnalysis = {
shot_summary: {
total_time_s: Math.round(totalTime * 10) / 10,
final_weight_g: Math.round(finalWeight * 10) / 10,
final_weight_g: Math.round(actualFinalWeight * 10) / 10,
target_weight_g: targetWeight,
weight_deviation_pct: targetWeight ? Math.round(((finalWeight - targetWeight) / targetWeight) * 1000) / 10 : 0,
weight_deviation_pct: targetWeight ? Math.round(((actualFinalWeight - targetWeight) / targetWeight) * 1000) / 10 : 0,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Added — regression test verifies shots with retracting data points use the settled weight (35.9g), not the stop-signal weight (33.5g).

Comment on lines +2828 to +2874
// 6. Call Gemini (with retry on transient errors)
const { GoogleGenAI: GenAI } = await import('@google/genai')
const apiKey = localStorage.getItem(STORAGE_KEYS.GEMINI_API_KEY) ?? ''
if (!apiKey) return jsonResponse({ status: 'error', message: 'Gemini API key not configured.' })
const client = new GenAI({ apiKey })
const modelId = localStorage.getItem(STORAGE_KEYS.GEMINI_MODEL) || 'gemini-2.5-flash'
const response = await client.models.generateContent({
model: modelId,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})

const isRetryable = (err: unknown): boolean => {
const m = err instanceof Error ? err.message : String(err)
return m.includes('503') || m.includes('UNAVAILABLE') || m.includes('overloaded') || m.includes('RESOURCE_EXHAUSTED') || m.includes('429')
}

let response
let lastErr: unknown
for (let attempt = 0; attempt <= 2; attempt++) {
try {
response = await client.models.generateContent({
model: modelId,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})
break
} catch (err) {
lastErr = err
if (attempt < 2 && isRetryable(err)) {
await new Promise(r => setTimeout(r, 2000 * Math.pow(2, attempt)))
continue
}
throw err
}
}
if (!response) throw lastErr

const analysisText = response.text ?? ''
return jsonResponse({
status: 'success',
llm_analysis: analysisText,
cached: false,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Analysis failed'
const raw = err instanceof Error ? err.message : String(err)
let msg: string
if (raw.includes('503') || raw.includes('UNAVAILABLE') || raw.includes('overloaded'))
msg = 'The AI model is temporarily unavailable. Please try again in a moment.'
else if (raw.includes('429') || raw.includes('RESOURCE_EXHAUSTED') || raw.includes('quota'))
msg = 'API quota exceeded. Please wait a moment and try again.'
else
msg = raw || 'Analysis failed'
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Added — 19 tests in retryUtils.test.ts cover: isRetryableError (transient detection + rejection), retryWithBackoff (success/retry/exhaust/non-transient), and formatGeminiError (all categories + fallback).

hessius and others added 4 commits May 3, 2026 20:07
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1. Extract shared retry/error utilities into retryUtils.ts, eliminating
   duplication between BrowserAIService and DirectModeInterceptor.
2. Include retraction phase in total_time_s (aligns with final_weight_g).
3. Add synthetic 'Drip (post-retraction)' stage when weight increases
   during retraction, closing the gap between last stage endWeight and
   final_weight_g so the AI doesn't misdiagnose under-yield.
4. Add 19 regression tests: retryUtils (isRetryableError, retryWithBackoff,
   formatGeminiError) + interceptor settled-weight verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…back

- Make subtitle/author text same color as title in ControlCenter and ProfileDropdown
- Replace generic 'Espresso profile' fallback with smart description from profile data
  (e.g., '3 stages · 36 g @ 93°C') when no real description exists
- Extend DropdownProfile with temperature, final_weight, stageCount metadata
- Add i18n stageCount key to all 6 locales

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rewrote APP_REVIEW_NOTES.md for direct copy-paste into App Store Connect.
Removed markdown formatting, eliminated redundancy, consolidated privacy
and permissions info. 3161 chars (limit: 4000).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hessius hessius merged commit 4c1f816 into version/2.4.0 May 4, 2026
5 checks passed
@hessius hessius deleted the fix/testflight-feedback branch May 4, 2026 06:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants