Skip to content

Commit 513e67e

Browse files
committed
Wire the brew flow through the run coordinator with link-loss recovery
1 parent 57c15e4 commit 513e67e

10 files changed

Lines changed: 252 additions & 65 deletions

File tree

src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"brew.done": "Done",
2323
"brew.startBrewing": "Start brewing",
2424
"brew.redoBrew": "Redo brew",
25+
"brew.linkLost": "Scale disconnected · reconnecting…",
26+
"brew.convertToManual": "Switch to manual entry",
2527
"brew.saving": "Saving...",
2628
"brew.saved": "Saved",
2729
"brew.saveFailed": "Save failed",

src/i18n/locales/ko.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"brew.done": "완료",
2323
"brew.startBrewing": "브루잉 시작",
2424
"brew.redoBrew": "다시 추출",
25+
"brew.linkLost": "저울 연결 끊김 · 재연결 중…",
26+
"brew.convertToManual": "수동 입력으로 전환",
2527
"brew.saving": "저장 중...",
2628
"brew.saved": "저장 완료",
2729
"brew.saveFailed": "저장 실패",

src/styles/base.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,19 @@
647647
color: var(--text-muted);
648648
}
649649

650+
.brew-flow-link-lost {
651+
display: flex;
652+
align-items: center;
653+
justify-content: space-between;
654+
gap: 8px;
655+
margin: 8px 0;
656+
padding: 6px 10px;
657+
border-radius: 6px;
658+
background-color: var(--background-modifier-error);
659+
color: var(--text-on-accent);
660+
font-size: var(--font-ui-small);
661+
}
662+
650663
.cubicj-brewing-view .brew-flow-stop-btn {
651664
background: var(--interactive-accent);
652665
color: var(--text-on-accent);

src/views/BrewingView.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,40 @@ import { ScaleDisplayManager } from './ScaleDisplayManager';
1010
import { type FlowStep, renderStep, getStepSummary, type StepRenderContext } from './StepRenderers';
1111
import { AccordionManager } from './AccordionManager';
1212
import { BrewProfileRecorder } from './BrewProfileRecorder';
13+
import { BrewRunCoordinator } from './BrewRunCoordinator';
1314
import { NobleInstallModal } from './NobleInstallModal';
1415

1516
export const VIEW_TYPE_BREWING = 'cubicj-brewing';
17+
const TIMER_OPERATION_TIMEOUT_MS = 5000;
18+
19+
export function withTimerOperationTimeout(operation: () => Promise<void>): Promise<void> {
20+
return new Promise((resolve, reject) => {
21+
const rejectWithError = (reason: unknown) => {
22+
reject(reason instanceof Error ? reason : new Error(String(reason)));
23+
};
24+
const timeoutId = window.setTimeout(() => {
25+
reject(new Error(`Timer operation timed out after ${TIMER_OPERATION_TIMEOUT_MS}ms`));
26+
}, TIMER_OPERATION_TIMEOUT_MS);
27+
let pending: Promise<void>;
28+
try {
29+
pending = operation();
30+
} catch (err) {
31+
window.clearTimeout(timeoutId);
32+
rejectWithError(err);
33+
return;
34+
}
35+
pending.then(
36+
() => {
37+
window.clearTimeout(timeoutId);
38+
resolve();
39+
},
40+
(err) => {
41+
window.clearTimeout(timeoutId);
42+
rejectWithError(err);
43+
},
44+
);
45+
});
46+
}
1647

1748
export class BrewingView extends ItemView {
1849
private plugin: CubicJBrewingPlugin;
@@ -29,6 +60,7 @@ export class BrewingView extends ItemView {
2960
private accordion!: AccordionManager;
3061

3162
private timerController!: TimerController;
63+
private runCoordinator!: BrewRunCoordinator;
3264
private recorder = new BrewProfileRecorder();
3365

3466
constructor(leaf: WorkspaceLeaf, plugin: CubicJBrewingPlugin) {
@@ -57,7 +89,7 @@ export class BrewingView extends ItemView {
5789
const svc = this.plugin.acaiaService!;
5890
this.scaleDisplay = new ScaleDisplayManager(this.scaleConnectBtn, this.scalePowerOffBtn, {
5991
onTimerClick: () => {
60-
void this.timerController.handleTimerClick();
92+
this.runCoordinator.handleToolbarTimer();
6193
},
6294
onTare: () => {
6395
void svc.tare();
@@ -71,8 +103,16 @@ export class BrewingView extends ItemView {
71103
const scaleElems = this.scaleDisplay.buildData(dataEl);
72104
this.timerController = new TimerController(
73105
{ timerEl: scaleElems.timerEl, timerBtn: scaleElems.timerBtn },
74-
{ startTimer: () => svc.startTimer(), stopTimer: () => svc.stopTimer(), resetTimer: () => svc.resetTimer() },
106+
{
107+
startTimer: () => withTimerOperationTimeout(() => svc.startTimer()),
108+
stopTimer: () => withTimerOperationTimeout(() => svc.stopTimer()),
109+
resetTimer: () => withTimerOperationTimeout(() => svc.resetTimer()),
110+
},
75111
);
112+
this.runCoordinator = new BrewRunCoordinator(this.flowState, this.recorder, this.timerController, {
113+
getScaleState: () => this.plugin.acaiaService?.state ?? 'idle',
114+
renderContent: (focusStep) => this.renderContent(focusStep),
115+
});
76116

77117
const contentArea = container.createDiv({ cls: 'brewing-content-area' });
78118
this.accordion = new AccordionManager(contentArea, {
@@ -111,7 +151,7 @@ export class BrewingView extends ItemView {
111151
}
112152

113153
toggleTimer(): void {
114-
void this.timerController.handleTimerClick();
154+
this.runCoordinator.handleToolbarTimer();
115155
}
116156

117157
powerOff(): void {
@@ -202,14 +242,7 @@ export class BrewingView extends ItemView {
202242
private resetFlow(): void {
203243
this.log('resetFlow');
204244
this.flowState.cancel();
205-
this.recorder.reset();
206-
if (this.plugin.acaiaService?.state === 'connected') {
207-
this.timerController.cancelRun().catch((err) => {
208-
console.error('[BrewingView] resetFlow timer cancel failed:', err);
209-
});
210-
} else {
211-
this.timerController.resetToIdle();
212-
}
245+
this.runCoordinator.resetAll();
213246
this.flowState.startBrew();
214247
this.accordion.clearExpandedSteps();
215248
this.lastFocusedStep = this.flowState.step;
@@ -229,6 +262,7 @@ export class BrewingView extends ItemView {
229262
updateSummaries: () => this.accordion.updateSummaries(),
230263
},
231264
timerController: this.timerController,
265+
runCoordinator: this.runCoordinator,
232266
getWeightText: () => this.scaleDisplay.getWeightText(),
233267
resetFlow: () => this.resetFlow(),
234268
recorder: this.recorder,
@@ -241,8 +275,11 @@ export class BrewingView extends ItemView {
241275
private bindServiceEvents(): void {
242276
this.listen('state', (state: AcaiaState) => {
243277
this.log(`state → ${state}`);
278+
this.runCoordinator.handleScaleState(state);
244279
this.scaleDisplay.updateHeader(state, this.plugin.acaiaService?.scaleName);
245-
this.scaleDisplay.updateControls(state, () => this.timerController.resetToIdle());
280+
this.scaleDisplay.updateControls(state, () => {
281+
if (!this.runCoordinator.hasActiveRun()) this.timerController.resetToIdle();
282+
});
246283
});
247284

248285
this.listen('weight', (grams: number, stable: boolean) => {
@@ -255,7 +292,7 @@ export class BrewingView extends ItemView {
255292
});
256293

257294
this.listen('button', (event: ButtonEvent) => {
258-
this.timerController.handleScaleButton(event);
295+
this.runCoordinator.handleScaleButton(event);
259296
});
260297

261298
this.listen('battery', (percent: number) => {

src/views/StepRenderers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { BrewFlowSelection, EquipmentSettings } from '../brew/types';
44
import type { TimerController } from './TimerController';
55
import { formatTimer } from './TimerController';
66
import type { BrewProfileRecorder } from './BrewProfileRecorder';
7+
import type { BrewRunCoordinator } from './BrewRunCoordinator';
78
import type { BrewProfileStorage } from '../services/BrewProfileStorage';
89
import { getDrinkLabel, getMethodLabel, getTempLabel, calcRoastDays } from '../brew/constants';
910
import { t } from '../i18n/index';
@@ -39,6 +40,7 @@ export interface StepRenderContext {
3940
renderContent: (focusStep?: FlowStep) => void;
4041
accordion: AccordionActions;
4142
timerController: TimerController;
43+
runCoordinator: BrewRunCoordinator;
4244
getWeightText: () => string;
4345
resetFlow: () => void;
4446
recorder: BrewProfileRecorder;

src/views/steps/renderBrewing.ts

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Notice } from 'obsidian';
2-
import { estimateYield } from '../../brew/yieldEstimator';
32
import { BrewProfileChart } from '../BrewProfileChart';
43
import { BrewProfileModal } from '../BrewProfileModal';
54
import { createStepper } from '../Stepper';
@@ -13,7 +12,6 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
1312
return;
1413
}
1514
const isEspresso = ctx.flowState.selection.method === 'espresso';
16-
const scaleConnected = ctx.plugin.acaiaService?.state === 'connected';
1715

1816
if (isEspresso) {
1917
container.createDiv({ cls: 'brew-flow-espresso-msg', text: t('brew.espressoMsg') });
@@ -46,7 +44,7 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
4644

4745
if (ctx.flowState.brewingStarted) {
4846
let chart: BrewProfileChart | null = null;
49-
if (scaleConnected) {
47+
if (ctx.runCoordinator.isScaleModeRun()) {
5048
const chartContainer = container.createDiv({ cls: 'brew-profile-container' });
5149
const liveChart = new BrewProfileChart(chartContainer);
5250
chart = liveChart;
@@ -55,6 +53,17 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
5553
} else {
5654
renderManualResultSteppers(container, ctx);
5755
}
56+
if (ctx.runCoordinator.isLinkLost()) {
57+
const banner = container.createDiv({ cls: 'brew-flow-link-lost' });
58+
banner.createSpan({ text: t('brew.linkLost') });
59+
const convertBtn = banner.createEl('button', {
60+
text: t('brew.convertToManual'),
61+
cls: 'brewing-ctrl-btn brew-flow-convert-btn',
62+
});
63+
convertBtn.addEventListener('click', () => {
64+
ctx.runCoordinator.convertToManual();
65+
});
66+
}
5867
const controls = container.createDiv({ cls: 'brewing-controls' });
5968
const stopBtn = controls.createEl('button', { text: t('brew.done'), cls: 'brewing-ctrl-btn brew-flow-stop-btn' });
6069
const cancelBtn = controls.createEl('button', {
@@ -69,19 +78,7 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
6978
cancelBtn.disabled = true;
7079
try {
7180
if (chart) chart.stopLive();
72-
if (scaleConnected) {
73-
ctx.recorder.stop();
74-
await ctx.timerController.freeze();
75-
const totalSeconds = ctx.timerController.getElapsedSeconds();
76-
const yieldGrams =
77-
(ctx.flowState.selection.method === 'filter' ? estimateYield(ctx.recorder.getPoints()) : undefined) ??
78-
(parseFloat(ctx.getWeightText()) || undefined);
79-
ctx.flowState.finishBrewing(totalSeconds || undefined, yieldGrams);
80-
} else {
81-
const sel = ctx.flowState.selection;
82-
ctx.flowState.finishBrewing(sel.time, sel.yield);
83-
}
84-
ctx.renderContent();
81+
if (await ctx.runCoordinator.finishRun()) ctx.renderContent();
8582
} catch (err) {
8683
console.error('[StepRenderers] brew stop failed:', err);
8784
new Notice(t('brew.unexpectedError'));
@@ -101,10 +98,7 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
10198
cancelBtn.disabled = true;
10299
try {
103100
if (chart) chart.stopLive();
104-
ctx.recorder.reset();
105-
ctx.flowState.cancelBrewingRun();
106-
if (scaleConnected) await ctx.timerController.cancelRun();
107-
else ctx.timerController.resetToIdle();
101+
await ctx.runCoordinator.cancelRun();
108102
} catch (err) {
109103
console.error('[StepRenderers] brew cancel failed:', err);
110104
new Notice(t('brew.unexpectedError'));
@@ -127,19 +121,13 @@ export function renderBrewing(container: HTMLElement, ctx: StepRenderContext): v
127121
startPending = true;
128122
startBtn.disabled = true;
129123
try {
130-
if (!ctx.flowState.beginBrewingRun()) return;
131-
if (scaleConnected) {
132-
ctx.recorder.start();
133-
await ctx.timerController.handleTimerClick();
124+
if (await ctx.runCoordinator.startRun()) {
125+
ctx.accordion.update();
126+
ctx.accordion.scrollToStep('brewing');
134127
}
135-
ctx.accordion.update();
136-
ctx.accordion.scrollToStep('brewing');
137128
} catch (err) {
138129
console.error('[StepRenderers] brew start failed:', err);
139130
new Notice(t('brew.unexpectedError'));
140-
ctx.flowState.cancelBrewingRun();
141-
ctx.recorder.reset();
142-
ctx.renderContent('brewing');
143131
} finally {
144132
startPending = false;
145133
startBtn.disabled = false;
@@ -199,9 +187,8 @@ function renderReview(container: HTMLElement, ctx: StepRenderContext): void {
199187
const staticChart = new BrewProfileChart(chartContainer);
200188
ctx.registerCleanup(() => staticChart.destroy());
201189
staticChart.renderStatic(points);
202-
} else {
203-
renderManualResultSteppers(container, ctx);
204190
}
191+
renderManualResultSteppers(container, ctx);
205192

206193
const controls = container.createDiv({ cls: 'brewing-controls' });
207194
const redoBtn = controls.createEl('button', { text: t('brew.redoBrew'), cls: 'brewing-ctrl-btn brew-flow-redo-btn' });

tests/views/steps/renderBean.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function makeContext(flowState: BrewFlowState): StepRenderContext {
3333
updateSummaries: vi.fn(),
3434
},
3535
timerController: {} as StepRenderContext['timerController'],
36+
runCoordinator: {} as StepRenderContext['runCoordinator'],
3637
getWeightText: vi.fn(() => ''),
3738
resetFlow: vi.fn(),
3839
recorder: {} as StepRenderContext['recorder'],

0 commit comments

Comments
 (0)