From c059d24240909a4449b64f68f7bf9c778860239b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 17:15:08 +0000 Subject: [PATCH 1/4] Fix Sheets range parse error by appending cell reference to sheet name The Sheets API requires a full A1 notation range (e.g. 'Tip History'!A:A), not just a quoted sheet name. Appending !A:A satisfies the parser for all sheet names including those with spaces. https://claude.ai/code/session_011LY1ZZDXKuvy13nZQpySZU --- src/lib/server/sheets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index fd62874..06b2147 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -23,7 +23,7 @@ export async function appendToSheet( credJson: string ): Promise { const token = await getToken(credJson); - const range = encodeURIComponent(`'${sheetName}'`); + const range = encodeURIComponent(`'${sheetName}'!A:A`); const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const res = await fetch(url, { From d5f448026c32a8bf0ba05f3326e1ab1afe0b436a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 17:17:16 +0000 Subject: [PATCH 2/4] Use !A1 cell anchor instead of !A:A for Sheets append range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit !A1 is the idiomatic anchor for values.append — it identifies the sheet and starting cell without implying any column constraint. https://claude.ai/code/session_011LY1ZZDXKuvy13nZQpySZU --- src/lib/server/sheets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index 06b2147..a95393c 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -23,7 +23,7 @@ export async function appendToSheet( credJson: string ): Promise { const token = await getToken(credJson); - const range = encodeURIComponent(`'${sheetName}'!A:A`); + const range = encodeURIComponent(`'${sheetName}'!A1`); const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const res = await fetch(url, { From c6f8d2ffc1964f51d292aaf73f5fed29aaed2e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 17:20:20 +0000 Subject: [PATCH 3/4] Only write header row on first export to an empty sheet Check if the sheet already has data before appending; include the header row only when the sheet is empty. Subsequent exports append the data row only. https://claude.ai/code/session_011LY1ZZDXKuvy13nZQpySZU --- src/lib/server/sheets.ts | 25 +++++++++++++++++++++---- src/routes/api/export/+server.ts | 3 ++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index a95393c..deb7e2c 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -12,20 +12,37 @@ async function getToken(credJson: string): Promise { return token; } +async function sheetIsEmpty( + spreadsheetId: string, + range: string, + token: string +): Promise { + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) return true; // treat unreadable as empty; append will surface real errors + const body = await res.json(); + return !body.values || body.values.length === 0; +} + /** - * Append rows to a Google Sheet. - * Values are plain strings/numbers; Sheets will auto-detect dates and numbers. + * Append a data row to a Google Sheet. + * Writes the header row first only when the sheet is empty. */ export async function appendToSheet( spreadsheetId: string, sheetName: string, - values: (string | number)[][], + headerRow: (string | number)[], + dataRow: (string | number)[], credJson: string ): Promise { const token = await getToken(credJson); const range = encodeURIComponent(`'${sheetName}'!A1`); - const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; + const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`; + + const empty = await sheetIsEmpty(spreadsheetId, range, token); + const values = empty ? [headerRow, dataRow] : [dataRow]; + const url = `${base}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const res = await fetch(url, { method: 'POST', headers: { diff --git a/src/routes/api/export/+server.ts b/src/routes/api/export/+server.ts index dc4fae0..a05941d 100644 --- a/src/routes/api/export/+server.ts +++ b/src/routes/api/export/+server.ts @@ -52,7 +52,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { await appendToSheet( spreadsheetId, settings.google_sheets_sheet_name || 'Tip History', - [headerRow, dataRow], + headerRow, + dataRow, credJson ); } catch (e) { From 5bcd35f014b04c2c3fa4fd6fd2d37e9ffc8a2893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 17:29:48 +0000 Subject: [PATCH 4/4] Reformat Sheets export to normalized rows (Option C) Each export now writes one summary row + one row per staff member, all sharing a fixed 20-column schema: Date, Shift, Type, Calc ID, Gross Tips, CC Fee Rate, CC Fees, Net Tips, Kitchen %, Kitchen Pool, Liquor Sales, Bar Liquor %, Bar Pool, FOH Pool, Name, Role, FOH Share, Bar Share, Kitchen Share, Total Type column is 'summary' or 'staff'. Calc ID ties rows together and traces back to the app DB. Header written once on empty sheet only. https://claude.ai/code/session_011LY1ZZDXKuvy13nZQpySZU --- src/lib/server/sheets.ts | 16 +++++++++++----- src/routes/api/export/+server.ts | 29 ++++++++++++++++++----------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index deb7e2c..26532dd 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -19,20 +19,26 @@ async function sheetIsEmpty( ): Promise { const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) return true; // treat unreadable as empty; append will surface real errors + if (!res.ok) return true; const body = await res.json(); return !body.values || body.values.length === 0; } +export const HEADER_ROW = [ + 'Date', 'Shift', 'Type', 'Calc ID', + 'Gross Tips', 'CC Fee Rate', 'CC Fees', 'Net Tips', + 'Kitchen %', 'Kitchen Pool', 'Liquor Sales', 'Bar Liquor %', 'Bar Pool', 'FOH Pool', + 'Name', 'Role', 'FOH Share', 'Bar Share', 'Kitchen Share', 'Total', +]; + /** - * Append a data row to a Google Sheet. + * Append rows to a Google Sheet. * Writes the header row first only when the sheet is empty. */ export async function appendToSheet( spreadsheetId: string, sheetName: string, - headerRow: (string | number)[], - dataRow: (string | number)[], + dataRows: (string | number)[][], credJson: string ): Promise { const token = await getToken(credJson); @@ -40,7 +46,7 @@ export async function appendToSheet( const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`; const empty = await sheetIsEmpty(spreadsheetId, range, token); - const values = empty ? [headerRow, dataRow] : [dataRow]; + const values = empty ? [HEADER_ROW, ...dataRows] : dataRows; const url = `${base}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const res = await fetch(url, { diff --git a/src/routes/api/export/+server.ts b/src/routes/api/export/+server.ts index a05941d..9ed5c02 100644 --- a/src/routes/api/export/+server.ts +++ b/src/routes/api/export/+server.ts @@ -26,15 +26,13 @@ export const POST: RequestHandler = async ({ request, locals }) => { const credJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; if (!credJson) error(400, 'GOOGLE_SERVICE_ACCOUNT_JSON env var not set.'); - // Build the row matching PRD OR-8 - const headerRow = [ - 'Date', 'Shift', 'Gross Tips', 'CC Fee Rate', 'CC Fees', 'Tips After Fees', - 'Kitchen %', 'Kitchen Pool', 'Liquor Sales', 'Bar Liquor %', 'Bar Pool', 'FOH Pool', - ...dists.map(d => d.name), - ]; + // Columns: Date, Shift, Type, Calc ID, + // Gross Tips, CC Fee Rate, CC Fees, Net Tips, + // Kitchen %, Kitchen Pool, Liquor Sales, Bar Liquor %, Bar Pool, FOH Pool, + // Name, Role, FOH Share, Bar Share, Kitchen Share, Total - const dataRow = [ - calc.date, calc.shift, + const summaryRow: (string | number)[] = [ + calc.date, calc.shift, 'summary', calc.id, formatCents(calc.gross_tips_cents), `${(calc.cc_fee_rate * 100).toFixed(1)}%`, formatCents(calc.cc_fees_cents), @@ -45,15 +43,24 @@ export const POST: RequestHandler = async ({ request, locals }) => { `${(calc.bar_liquor_pct * 100).toFixed(0)}%`, formatCents(calc.bar_pool_cents), formatCents(calc.foh_pool_cents), - ...dists.map(d => formatCents(d.total_cents)), + '', '', '', '', '', '', ]; + const staffRows: (string | number)[][] = dists.map(d => [ + calc.date, calc.shift, 'staff', calc.id, + '', '', '', '', '', '', '', '', '', '', + d.name, d.role, + formatCents(d.foh_share_cents), + formatCents(d.bar_pool_share_cents), + formatCents(d.kitchen_share_cents), + formatCents(d.total_cents), + ]); + try { await appendToSheet( spreadsheetId, settings.google_sheets_sheet_name || 'Tip History', - headerRow, - dataRow, + [summaryRow, ...staffRows], credJson ); } catch (e) {