Skip to content

Commit 2300d7e

Browse files
committed
Add Google-form feedback, file-open & UI tweaks
Switch feedback flow from mailto/attachments to opening a configurable Google Form (FEEDBACK_FORM_URL + prefill entry IDs). Add loadLocalEnvForElectron to load .env(.local) into the Electron main/preload processes. Implement single-instance handling and OS integration for opening .budget files (open-file, second-instance, pending startup file dispatch, and IPC event menu:open-budget-file), and hook the renderer to loadBudget on such events. Improve drag-and-drop UX for tabs and the tab-position handle (custom data types, more robust drag-over/drop handling, adaptive thresholds, visual feedback) and fix related event wiring. Apply multiple CSS/UI refinements (sticky page header, color-mix usage for accent/drag indicators, small shape/spacing tweaks, focus styles) and update types and preload API to expose the new open-budget-file menu event. Small copy updates in the feedback modal messaging to reflect the new form workflow.
1 parent 38ca6d0 commit 2300d7e

17 files changed

Lines changed: 368 additions & 167 deletions

File tree

electron/constants.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,17 @@
1-
export const SUPPORT_EMAIL = 'paycheckplannersupport@gmail.com';
1+
import { loadLocalEnvForElectron } from './utils/loadEnv';
2+
3+
loadLocalEnvForElectron();
4+
5+
// Set this to your Google Form view URL, for example:
6+
// https://docs.google.com/forms/d/e/<FORM_ID>/viewform
7+
export const FEEDBACK_FORM_URL = process.env.FEEDBACK_FORM_URL || '';
8+
9+
// Optional prefill entry IDs from your Google Form.
10+
// Leave blank to open the form without prefilled values.
11+
export const FEEDBACK_FORM_ENTRY_IDS = {
12+
email: process.env.FEEDBACK_FORM_ENTRY_EMAIL || '',
13+
category: process.env.FEEDBACK_FORM_ENTRY_CATEGORY || '',
14+
subject: process.env.FEEDBACK_FORM_ENTRY_SUBJECT || '',
15+
details: process.env.FEEDBACK_FORM_ENTRY_DETAILS || '',
16+
};
17+

electron/main.ts

Lines changed: 128 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@ import * as path from 'path';
77
import * as fs from 'fs/promises';
88
import { fileURLToPath } from 'url';
99
import { createRequire } from 'module';
10-
import { execFile } from 'child_process';
11-
import { promisify } from 'util';
12-
import { SUPPORT_EMAIL } from './constants';
10+
import { FEEDBACK_FORM_ENTRY_IDS, FEEDBACK_FORM_URL } from './constants';
1311

1412
// Create require function for ES modules
1513
const require = createRequire(import.meta.url);
16-
const execFileAsync = promisify(execFile);
1714

1815
const MAX_LOG_BUFFER_ENTRIES = 2000;
1916
const appLogBuffer: string[] = [];
@@ -130,6 +127,11 @@ const __dirname = path.dirname(__filename);
130127
// Set the app name - this is displayed in menus and dialogs
131128
app.name = 'Paycheck Planner';
132129

130+
const hasSingleInstanceLock = app.requestSingleInstanceLock();
131+
if (!hasSingleInstanceLock) {
132+
app.quit();
133+
}
134+
133135
// Configure the About panel (macOS only)
134136
if (process.platform === 'darwin') {
135137
app.setAboutPanelOptions({
@@ -168,6 +170,72 @@ let welcomeWindow: BrowserWindow | null = null;
168170
let mainWindow: BrowserWindow | null = null;
169171
// Track if the app is quitting to avoid reopening welcome window
170172
let isQuitting = false;
173+
// Track file requested via OS open-file/Open With integration.
174+
let pendingExternalBudgetFilePath: string | null = null;
175+
176+
function isBudgetFilePath(filePath: string): boolean {
177+
const extension = path.extname(filePath).toLowerCase();
178+
return extension === '.budget';
179+
}
180+
181+
function extractBudgetFilePathFromArgv(argv: string[]): string | null {
182+
for (const arg of argv) {
183+
if (!arg || arg.startsWith('-')) continue;
184+
if (isBudgetFilePath(arg)) return arg;
185+
}
186+
return null;
187+
}
188+
189+
function getPrimaryWindow(): BrowserWindow | null {
190+
const focused = BrowserWindow.getFocusedWindow();
191+
if (focused && !focused.isDestroyed()) return focused;
192+
if (hasLiveWelcomeWindow()) return welcomeWindow;
193+
194+
const firstPlanWindow = Array.from(openWindows)[0];
195+
if (firstPlanWindow && !firstPlanWindow.isDestroyed()) return firstPlanWindow;
196+
197+
const allWindows = BrowserWindow.getAllWindows();
198+
if (allWindows.length > 0 && !allWindows[0].isDestroyed()) return allWindows[0];
199+
200+
return null;
201+
}
202+
203+
function dispatchExternalBudgetOpen(filePath: string) {
204+
const targetWindow = getPrimaryWindow();
205+
if (!targetWindow) {
206+
pendingExternalBudgetFilePath = filePath;
207+
return;
208+
}
209+
210+
const sendOpenFileEvent = () => {
211+
targetWindow.webContents.send('menu:open-budget-file', filePath);
212+
pendingExternalBudgetFilePath = null;
213+
targetWindow.show();
214+
targetWindow.focus();
215+
};
216+
217+
if (targetWindow.webContents.isLoading()) {
218+
targetWindow.webContents.once('did-finish-load', sendOpenFileEvent);
219+
} else {
220+
sendOpenFileEvent();
221+
}
222+
}
223+
224+
function handleExternalOpenFile(filePath: string) {
225+
if (!filePath || !isBudgetFilePath(filePath)) {
226+
debug('Ignoring non-budget external file open request:', filePath);
227+
return;
228+
}
229+
230+
debug('Handling external file open request:', filePath);
231+
dispatchExternalBudgetOpen(filePath);
232+
}
233+
234+
// On macOS this fires when users double-click associated files in Finder.
235+
app.on('open-file', (event, filePath) => {
236+
event.preventDefault();
237+
handleExternalOpenFile(filePath);
238+
});
171239

172240
function hasLiveWelcomeWindow(): boolean {
173241
return !!welcomeWindow && !welcomeWindow.isDestroyed();
@@ -470,7 +538,12 @@ function createWelcomeWindow(skipSessionRestore = false, windowState?: { width?:
470538

471539
welcomeWindow.on('closed', () => {
472540
welcomeWindow = null;
473-
// Don't auto-create another welcome window
541+
// If no plan windows are open, quit the app instead of staying open with no windows
542+
if (openWindows.size === 0 && !isQuitting) {
543+
isQuitting = true;
544+
app.quit();
545+
}
546+
// Otherwise, don't auto-create another welcome window
474547
// Only plan windows trigger welcome window creation
475548
});
476549
}
@@ -506,6 +579,12 @@ async function createWindow() {
506579
debug('Creating welcome window with default size');
507580
createWelcomeWindow();
508581

582+
const startupFilePath = pendingExternalBudgetFilePath || extractBudgetFilePathFromArgv(process.argv);
583+
if (startupFilePath) {
584+
pendingExternalBudgetFilePath = startupFilePath;
585+
dispatchExternalBudgetOpen(startupFilePath);
586+
}
587+
509588
// Create application menu with File and Edit options
510589
createApplicationMenu();
511590

@@ -958,6 +1037,21 @@ app.on('before-quit', () => {
9581037
// Wait for Electron to be ready, then create the window
9591038
app.whenReady().then(createWindow);
9601039

1040+
app.on('second-instance', (_event, argv) => {
1041+
const openedFile = extractBudgetFilePathFromArgv(argv);
1042+
if (openedFile) {
1043+
handleExternalOpenFile(openedFile);
1044+
return;
1045+
}
1046+
1047+
const targetWindow = getPrimaryWindow();
1048+
if (targetWindow) {
1049+
if (targetWindow.isMinimized()) targetWindow.restore();
1050+
targetWindow.show();
1051+
targetWindow.focus();
1052+
}
1053+
});
1054+
9611055
// On macOS, apps typically stay open even when all windows are closed
9621056
// On Windows/Linux, show welcome window when all windows are closed
9631057
app.on('window-all-closed', () => {
@@ -1125,7 +1219,7 @@ ipcMain.handle('export-pdf', async (event, filePath: string, pdfData: Uint8Array
11251219

11261220
/**
11271221
* Submit feedback
1128-
* Opens a prefilled support email in the user's default email client
1222+
* Opens a Google Form for feedback collection.
11291223
*/
11301224
ipcMain.handle('submit-feedback', async (_event, payload: {
11311225
email?: string;
@@ -1142,103 +1236,45 @@ ipcMain.handle('submit-feedback', async (_event, payload: {
11421236
};
11431237
}) => {
11441238
try {
1145-
const timestamp = new Date();
1146-
const id = `${timestamp.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
1147-
const feedbackTempDir = path.join(app.getPath('temp'), 'paycheck-planner-feedback', id);
1148-
await fs.mkdir(feedbackTempDir, { recursive: true });
1149-
1150-
const attachmentPaths: string[] = [];
1151-
1152-
if (payload.screenshot?.dataUrl) {
1153-
const match = payload.screenshot.dataUrl.match(/^data:(.+);base64,(.*)$/);
1154-
if (match) {
1155-
const mimeType = match[1] || payload.screenshot.mimeType || 'image/png';
1156-
const ext = mimeType.split('/')[1] || 'png';
1157-
const screenshotPath = path.join(feedbackTempDir, `screenshot.${ext}`);
1158-
await fs.writeFile(screenshotPath, Buffer.from(match[2], 'base64'));
1159-
attachmentPaths.push(screenshotPath);
1160-
}
1239+
if (!FEEDBACK_FORM_URL.trim()) {
1240+
return {
1241+
success: false,
1242+
error: 'Feedback form URL is not configured. Set FEEDBACK_FORM_URL in the Electron environment.',
1243+
};
11611244
}
11621245

1163-
const formattedDetailsPath = path.join(feedbackTempDir, 'feedback-details.html');
1164-
const detailsHtmlDoc = `<!doctype html><html><head><meta charset="utf-8"><title>Feedback Details</title></head><body>${payload.messageHtml}</body></html>`;
1165-
await fs.writeFile(formattedDetailsPath, detailsHtmlDoc, 'utf-8');
1166-
attachmentPaths.push(formattedDetailsPath);
1246+
const formUrl = new URL(FEEDBACK_FORM_URL);
1247+
const prefillParams = new URLSearchParams(formUrl.search);
1248+
prefillParams.set('usp', 'pp_url');
11671249

1168-
if (payload.includeDiagnostics && payload.diagnostics) {
1169-
const diagnosticsPath = path.join(feedbackTempDir, 'diagnostics.json');
1170-
await fs.writeFile(diagnosticsPath, JSON.stringify(payload.diagnostics, null, 2), 'utf-8');
1171-
attachmentPaths.push(diagnosticsPath);
1172-
}
1250+
const appendPrefill = (entryId: string, value?: string) => {
1251+
if (!entryId || !value || !value.trim()) return;
1252+
prefillParams.set(entryId, value.trim());
1253+
};
11731254

1174-
const logPath = path.join(feedbackTempDir, 'feedback-log.txt');
1175-
const consoleLogHeader = [
1176-
`Feedback Timestamp: ${timestamp.toISOString()}`,
1177-
`Category: ${payload.category}`,
1178-
`From: ${payload.email || 'Not provided'}`,
1179-
`Subject: ${payload.subject}`,
1180-
'',
1181-
'--- App Debug/Info Console Output ---',
1182-
];
1183-
const consoleLogContent = appLogBuffer.length > 0 ? appLogBuffer.join('\n') : 'No captured debug/info logs available.';
1184-
await fs.writeFile(logPath, `${consoleLogHeader.join('\n')}\n${consoleLogContent}\n`, 'utf-8');
1185-
attachmentPaths.push(logPath);
1186-
1187-
const lines: string[] = [
1188-
`Category: ${payload.category}`,
1189-
`From: ${payload.email || 'Not provided'}`,
1190-
'',
1191-
'Message:',
1192-
payload.messageText,
1193-
];
1194-
1195-
lines.push('', 'Attachment files have been prepared by the app.');
1196-
1197-
if (process.platform === 'darwin' && attachmentPaths.length > 0) {
1198-
const escapeAppleScript = (value: string): string =>
1199-
value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1200-
1201-
const bodyText = `${lines.join('\n')}\n`;
1202-
const scriptLines = [
1203-
'tell application "Mail"',
1204-
`set newMessage to make new outgoing message with properties {subject:"${escapeAppleScript(`[Paycheck Planner Feedback] ${payload.subject}`)}", content:"${escapeAppleScript(bodyText)}", visible:true}`,
1205-
'tell newMessage',
1206-
`make new to recipient at end of to recipients with properties {address:"${escapeAppleScript(SUPPORT_EMAIL)}"}`,
1207-
'end tell',
1208-
];
1209-
1210-
attachmentPaths.forEach((attachmentPath) => {
1211-
scriptLines.push(
1212-
`tell content of newMessage to make new attachment with properties {file name:POSIX file "${escapeAppleScript(attachmentPath)}"} at after the last paragraph`
1213-
);
1214-
});
1255+
const categoryLabelMap: Record<typeof payload.category, string> = {
1256+
bug: 'Bug',
1257+
feature: 'Feature request',
1258+
ui: 'UI improvement',
1259+
performance: 'Performance issue',
1260+
other: 'Other',
1261+
};
12151262

1216-
scriptLines.push('activate');
1217-
scriptLines.push('end tell');
1218-
1219-
const scriptPath = path.join(feedbackTempDir, 'create-mail-draft.applescript');
1220-
await fs.writeFile(scriptPath, scriptLines.join('\n'), 'utf-8');
1221-
1222-
try {
1223-
await execFileAsync('osascript', [scriptPath]);
1224-
return { success: true };
1225-
} catch (error) {
1226-
console.error('Error creating Apple Mail draft with attachments:', error);
1227-
}
1228-
}
1263+
// Prefill the Google Form "Details" question with exactly what the user typed.
1264+
const details = payload.messageText.trim().slice(0, 5000);
12291265

1230-
// Fallback path for non-macOS or if Apple Mail automation fails.
1231-
await shell.openPath(feedbackTempDir);
1266+
appendPrefill(FEEDBACK_FORM_ENTRY_IDS.email, payload.email);
1267+
appendPrefill(FEEDBACK_FORM_ENTRY_IDS.category, categoryLabelMap[payload.category]);
1268+
appendPrefill(FEEDBACK_FORM_ENTRY_IDS.subject, payload.subject);
1269+
appendPrefill(FEEDBACK_FORM_ENTRY_IDS.details, details);
12321270

1233-
const encodedSubject = encodeURIComponent(`[Paycheck Planner Feedback] ${payload.subject}`);
1234-
const encodedBody = encodeURIComponent(lines.join('\n'));
1235-
const mailtoUrl = `mailto:${SUPPORT_EMAIL}?subject=${encodedSubject}&body=${encodedBody}`;
1271+
formUrl.search = prefillParams.toString();
12361272

1237-
await shell.openExternal(mailtoUrl);
1273+
await shell.openExternal(formUrl.toString());
12381274

12391275
return { success: true };
12401276
} catch (error) {
1241-
console.error('Error opening feedback email:', error);
1277+
console.error('Error opening feedback form:', error);
12421278
return { success: false, error: (error as Error).message };
12431279
}
12441280
});

electron/preload.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
5151
// Returns: { success: boolean, error?: string }
5252
exportPdf: (filePath: string, pdfData: Uint8Array) => ipcRenderer.invoke('export-pdf', filePath, pdfData),
5353

54-
// Submit tester feedback via email flow (with prepared attachments when available)
54+
// Submit tester feedback via Google Form flow
5555
submitFeedback: (payload: {
5656
email?: string;
5757
category: 'bug' | 'feature' | 'ui' | 'performance' | 'other';
@@ -80,7 +80,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
8080
// Takes: event name and callback function
8181
// Returns: () => unsubscribe function to remove listener
8282
onMenuEvent: (
83-
event: 'new-budget' | 'open-budget' | 'change-encryption' | 'save-plan' | 'open-settings' | 'open-about' | 'open-glossary' | 'open-pay-options' | 'open-accounts' | 'set-tab-position' | 'toggle-tab-display-mode',
83+
event: 'new-budget' | 'open-budget' | 'open-budget-file' | 'change-encryption' | 'save-plan' | 'open-settings' | 'open-about' | 'open-glossary' | 'open-pay-options' | 'open-accounts' | 'set-tab-position' | 'toggle-tab-display-mode',
8484
callback: (arg?: unknown) => void
8585
) => {
8686
const channel = `menu:${event}`;

electron/utils/loadEnv.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
/**
5+
* Load local .env files for Electron main/preload processes.
6+
* Priority: existing process.env > .env.local > .env
7+
*/
8+
export const loadLocalEnvForElectron = () => {
9+
const envCandidates = ['.env.local', '.env'];
10+
11+
for (const fileName of envCandidates) {
12+
const envPath = path.resolve(process.cwd(), fileName);
13+
if (!fs.existsSync(envPath)) continue;
14+
15+
const content = fs.readFileSync(envPath, 'utf-8');
16+
const lines = content.split(/\r?\n/);
17+
18+
for (const rawLine of lines) {
19+
const line = rawLine.trim();
20+
if (!line || line.startsWith('#')) continue;
21+
22+
const separatorIndex = line.indexOf('=');
23+
if (separatorIndex <= 0) continue;
24+
25+
const key = line.slice(0, separatorIndex).trim();
26+
if (!key || process.env[key] !== undefined) continue;
27+
28+
let value = line.slice(separatorIndex + 1).trim();
29+
if (
30+
(value.startsWith('"') && value.endsWith('"')) ||
31+
(value.startsWith("'") && value.endsWith("'"))
32+
) {
33+
value = value.slice(1, -1);
34+
}
35+
36+
process.env[key] = value;
37+
}
38+
}
39+
};

src/App.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function App() {
1515
if (import.meta.env.DEV) console.debug('[APP] App component rendering...');
1616

1717
// Get the current budget data and actions from our context
18-
const { budgetData, saveBudget, saveWindowState } = useBudget()
18+
const { budgetData, saveBudget, saveWindowState, loadBudget } = useBudget()
1919
if (import.meta.env.DEV) console.debug('[APP] Budget data available:', !!budgetData);
2020

2121
// Track whether user has completed initial setup
@@ -83,6 +83,19 @@ function App() {
8383
return unsubscribe
8484
}, [])
8585

86+
// Listen for file-open requests from OS integration (double click / Open With)
87+
useEffect(() => {
88+
if (!window.electronAPI?.onMenuEvent) return
89+
90+
const unsubscribe = window.electronAPI.onMenuEvent('open-budget-file', (arg) => {
91+
if (typeof arg === 'string' && arg.trim()) {
92+
loadBudget(arg)
93+
}
94+
})
95+
96+
return unsubscribe
97+
}, [loadBudget])
98+
8699
// Open glossary from in-app term tooltips
87100
useEffect(() => {
88101
type OpenGlossaryEvent = CustomEvent<{ termId?: string }>;

0 commit comments

Comments
 (0)