fix: TestFlight feedback — subtitle color, AI retry, weight accuracy#416
fix: TestFlight feedback — subtitle color, AI retry, weight accuracy#416hessius merged 5 commits intoversion/2.4.0from
Conversation
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>
There was a problem hiding this comment.
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. |
| 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' |
There was a problem hiding this comment.
Fixed — extracted shared retryUtils.ts with isRetryableError, retryWithBackoff, and formatGeminiError. Both BrowserAIService and DirectModeInterceptor now import from the single source. 19 regression tests added.
| 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%. |
There was a problem hiding this comment.
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.
| // 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, |
There was a problem hiding this comment.
Added — regression test verifies shots with retracting data points use the settled weight (35.9g), not the stop-signal weight (33.5g).
| // 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' |
There was a problem hiding this comment.
Added — 19 tests in retryUtils.test.ts cover: isRetryableError (transient detection + rejection), retryWithBackoff (success/retry/exhaust/non-transient), and formatGeminiError (all categories + fallback).
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>
TestFlight Feedback Fixes
1. Profile subtitle too faint (ControlCenter)
Changed
text-muted-foreground→text-foreground/70on 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
retryWithBackoffhelper (2 retries, exponential delay 2s→4s)generateContentcalls (BrowserAIService + DirectModeInterceptor)SERVICE_UNAVAILABLEerror code to AIServiceError type3. Shot weight assessment inaccuracy
Root cause: The AI prompt computed
final_weight_gby 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:
getHistoryMetrics()used by the static analysis UI)Testing