-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCode.gs
More file actions
286 lines (266 loc) · 9.77 KB
/
Copy pathCode.gs
File metadata and controls
286 lines (266 loc) · 9.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
* Conditioning Guide — Google Apps Script backend
*
* One Google Sheet, many tabs. Each entry type has its own tab. Headers are
* auto-managed: when the front-end writes a record with a new field, the
* corresponding column is appended on the fly — so adding a feature client-side
* never requires touching this script or the sheet by hand.
*
* Endpoints
* ─────────
* GET → returns { ok, vitals, activity, ekg, bloodSugar, symptoms, stageDecisions, doctorNotes, config }
* POST → JSON body, action ∈ {save, delete, bulkSave, saveConfig, ping}
* POSTs use Content-Type: text/plain to skip the CORS preflight that
* Apps Script web apps cannot answer.
*/
/* ─── tab names ─────────────────────────────────────────────────────────── */
const TABS = {
vitals: 'Vitals',
activity: 'Activity',
ekg: 'EKG',
bloodSugar: 'BloodSugar',
symptoms: 'Symptoms',
stageDecisions: 'StageDecisions',
doctorNotes: 'DoctorNotes'
};
const CONFIG_TAB = 'Config';
/* ─── seed headers ──────────────────────────────────────────────────────────
* Initial column order for each tab. The doPost handler self-extends when the
* front-end sends fields not yet present, so this list does NOT need updating
* every time the app gains a field. It's just the starting layout for a fresh
* sheet so it reads nicely. */
const SEED_HEADERS = {
vitals: [
'id', 'date', 'restingHR', 'weight',
'dizzy', 'swelling', 'breathing', 'symptomNote',
'tookMeds', 'createdAt', 'updatedAt'
],
activity: [
'id', 'date', 'category', 'subtypes',
'totalMinutes', 'rounds', 'minutesPerRound', 'restBetweenMin',
'borg', 'talkTestOK', 'recoveryHR', 'feeling',
'dizzyDuring', 'swellingDuring', 'breathingDuring', 'symptomNoteDuring',
'note', 'isProgrammed', 'createdAt', 'updatedAt'
],
ekg: [
'id', 'dateTime', 'rate', 'rhythm',
'symptomatic', 'note', 'createdAt', 'updatedAt'
],
bloodSugar: [
'id', 'dateTime', 'value', 'context',
'note', 'createdAt', 'updatedAt'
],
symptoms: [
'id', 'dateTime', 'dizzy', 'breathing', 'chest', 'palpitations',
'swelling', 'nausea', 'note', 'ekgId', 'createdAt', 'updatedAt'
],
stageDecisions: [
'id', 'weekStartDate', 'stage', 'choice', 'criteriaTicked',
'note', 'createdAt', 'updatedAt'
],
doctorNotes: [
'id', 'date', 'body', 'createdAt', 'updatedAt'
]
};
/* ─── sheet helpers ─────────────────────────────────────────────────────── */
/** Get (or create) a data tab and ensure it has at least its seed headers. */
function getSheet_(type) {
const tabName = TABS[type];
if (!tabName) throw new Error('Unknown type: ' + type);
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(tabName);
if (!sheet) sheet = ss.insertSheet(tabName);
if (sheet.getLastRow() === 0) {
const seed = SEED_HEADERS[type];
sheet.getRange(1, 1, 1, seed.length).setValues([seed]);
sheet.setFrozenRows(1);
}
return sheet;
}
/** Get the config k/v tab, ensuring its header row. */
function getConfigSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG_TAB);
if (!sheet) sheet = ss.insertSheet(CONFIG_TAB);
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, 2).setValues([['key', 'value']]);
sheet.setFrozenRows(1);
}
return sheet;
}
/** Read current headers for a sheet. */
function getHeaders_(sheet) {
const lastCol = sheet.getLastColumn();
if (lastCol === 0) return [];
return sheet.getRange(1, 1, 1, lastCol).getValues()[0];
}
/**
* Make sure every key in `record` has a column. Returns the (possibly extended)
* header array. This is what lets the app gain new fields without ever
* touching the script.
*/
function ensureHeadersFor_(sheet, record) {
let headers = getHeaders_(sheet);
const missing = Object.keys(record).filter(k => headers.indexOf(k) === -1);
if (missing.length > 0) {
const startCol = headers.length + 1;
sheet.getRange(1, startCol, 1, missing.length).setValues([missing]);
headers = headers.concat(missing);
}
return headers;
}
/** Cell-safe value. JSON-stringify nested structures so they fit in one cell. */
function flatten_(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return v;
}
/** Inverse of flatten_: parse cells that look like JSON arrays/objects. */
function reviveCell_(v) {
if (typeof v !== 'string') return v;
if (v.length < 2) return v;
const a = v.charAt(0), b = v.charAt(v.length - 1);
if ((a === '[' && b === ']') || (a === '{' && b === '}')) {
try { return JSON.parse(v); } catch (e) { return v; }
}
return v;
}
/** Upsert a single record into the right tab by `id`. */
function upsert_(type, record) {
const sheet = getSheet_(type);
const headers = ensureHeadersFor_(sheet, record);
const idCol = headers.indexOf('id');
if (idCol === -1) throw new Error('No id column for ' + type);
// Scan existing rows for matching id.
const values = sheet.getDataRange().getValues();
let rowIdx = -1;
for (let i = 1; i < values.length; i++) {
if (values[i][idCol] === record.id) { rowIdx = i + 1; break; }
}
const row = headers.map(h => flatten_(record[h]));
if (rowIdx > 0) {
sheet.getRange(rowIdx, 1, 1, headers.length).setValues([row]);
} else {
sheet.appendRow(row);
}
}
/** Delete a row by id. No-op if not found. */
function deleteById_(type, id) {
const sheet = getSheet_(type);
const headers = getHeaders_(sheet);
const idCol = headers.indexOf('id');
if (idCol === -1) return;
const values = sheet.getDataRange().getValues();
for (let i = 1; i < values.length; i++) {
if (values[i][idCol] === id) {
sheet.deleteRow(i + 1);
return;
}
}
}
/** Read every row from a tab as an array of objects. */
function readAll_(type) {
const sheet = getSheet_(type);
const values = sheet.getDataRange().getValues();
if (values.length < 2) return [];
const headers = values[0];
const idCol = headers.indexOf('id');
return values.slice(1)
.filter(r => idCol === -1 || r[idCol]) // drop blank rows
.map(row => {
const obj = {};
headers.forEach((h, i) => { obj[h] = reviveCell_(row[i]); });
return obj;
});
}
/** Read the entire config tab into an object. Values are JSON-parsed when possible. */
function readConfig_() {
const sheet = getConfigSheet_();
const values = sheet.getDataRange().getValues();
const cfg = {};
for (let i = 1; i < values.length; i++) {
const key = values[i][0];
if (!key) continue;
const raw = values[i][1];
try { cfg[key] = JSON.parse(raw); }
catch (e) { cfg[key] = raw; }
}
return cfg;
}
/** Upsert one config key. Always stringifies non-string values. */
function writeConfig_(key, value) {
const sheet = getConfigSheet_();
const values = sheet.getDataRange().getValues();
let rowIdx = -1;
for (let i = 1; i < values.length; i++) {
if (values[i][0] === key) { rowIdx = i + 1; break; }
}
const stored = typeof value === 'string' ? value : JSON.stringify(value);
if (rowIdx > 0) {
sheet.getRange(rowIdx, 2).setValue(stored);
} else {
sheet.appendRow([key, stored]);
}
}
/* ─── HTTP handlers ─────────────────────────────────────────────────────── */
function jsonOut_(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
/** GET → snapshot of everything. */
function doGet(e) {
try {
return jsonOut_({
ok: true,
vitals: readAll_('vitals'),
activity: readAll_('activity'),
ekg: readAll_('ekg'),
bloodSugar: readAll_('bloodSugar'),
symptoms: readAll_('symptoms'),
stageDecisions: readAll_('stageDecisions'),
doctorNotes: readAll_('doctorNotes'),
config: readConfig_()
});
} catch (err) {
return jsonOut_({ ok: false, error: String(err) });
}
}
/**
* POST → action-routed writes. Body MUST be JSON-stringified and sent with
* Content-Type: text/plain (the front-end does this) so we skip CORS preflight.
*
* Supported actions:
* {action:'ping'} - liveness check
* {action:'save', type, record} - upsert one
* {action:'bulkSave', type, records:[...]} - upsert many (same type)
* {action:'delete', type, id} - remove by id
* {action:'saveConfig', key, value} - upsert one config key
*/
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
if (data.action === 'ping') {
return jsonOut_({ ok: true, pong: true });
}
if (data.action === 'saveConfig') {
writeConfig_(data.key, data.value);
return jsonOut_({ ok: true });
}
if (data.action === 'save') {
upsert_(data.type, data.record);
return jsonOut_({ ok: true, id: data.record.id });
}
if (data.action === 'bulkSave') {
(data.records || []).forEach(r => upsert_(data.type, r));
return jsonOut_({ ok: true, count: (data.records || []).length });
}
if (data.action === 'delete') {
deleteById_(data.type, data.id);
return jsonOut_({ ok: true });
}
return jsonOut_({ ok: false, error: 'Unknown action: ' + data.action });
} catch (err) {
return jsonOut_({ ok: false, error: String(err) });
}
}