Dashboard
๐
Training Plan Bulanan
Template jadwal workout โ berlaku tiap bulan, bisa di-override per hari
๐
Kalender
๐ Result Analysis
๐ Minggu Ini
Klik hari untuk detail sesi
โ
โ
โถ
โ
Selesai
๐ Terjadwal
โ Terlewat
๐ค Rest
Sen
Sel
Rab
Kam
Jum
Sab
Min
๐ Progress Bulan Ini
Sesi yang sudah tercatat
โ
โ
0
Total Sesi Tercatat
๐ช
โ
0
Gym
๐ธ
โ
0
Badminton
๐
โ
0
Lari
๐ก Memuat insightโฆ
๐ Tren Training Load vs Recovery
Load = total training load (Workout Sessions) ยท Fatigue & Fitness Level = rata-rata harian (Daily Log)
๐ Komparasi Mingguan
Minggu ini vs beberapa minggu sebelumnya
๐ Dashboard Harian
Ringkasan kondisi tubuh hari ini, -
๐ Kondisi Hari Ini
Snapshot data pagi ini vs kemarin
โก
โ
-
HybridCharge (Battery)
โค๏ธ
โ
- bpm
Resting Heart Rate
๐
โ
- ms
HRV (Heart Rate Variability)
๐
โ
-
Steps
๐ฏ
โ
-
PAI (7-day)
๐ด
-
-
Durasi Tidur
๐ฉ
-
-/100
Fatigue Score
๐ค
-
-/100
Sleep Score
HybridCharge โ 14 Hari
Rentang PagiโMalam per hari ยท hijau = cukup, kuning = perhatian, merah = rendah (dari yang paling buruk Pagi/Malam)
Tren RHR & HRV โ 14 Hari
Resting Heart Rate vs Heart Rate Variability
Fatigue & Fitness Level โ 14 Hari
Fatigue Score vs Fitness Level (skala 0โ100)
Steps Harian โ 14 Hari
Target: 8.000 langkah/hari
Durasi & Kualitas Tidur โ 14 Hari
Deep Sleep ยท REM Sleep ยท Lainnya
๐ Workout Ranking
Top 3 ยท Badminton & Gym by Training Load/Sets, Lari by Jarak terjauh
๐ธ Badminton
| # | Tanggal | Durasi | Kalori | Avg HR | Load | Charge | HR Zone |
|---|---|---|---|---|---|---|---|
| 1 | 27 Jun | 1j 45m |
1.205 |
147 |
314 |
-12 |
|
| 2 | 20 Jun | 1j 50m |
1.260 |
151 |
320 |
-15 |
|
| 3 | 24 Jun | 1j 22m |
980 |
139 |
270 |
-9 |
๐ Lari
| # | Tanggal | Durasi | Kalori | Avg HR | Load | Charge | HR Zone | Jarak | Avg Pace |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 28 Jun | 43:23 |
456 |
154 |
248 |
-8 |
5.01 km |
8'39" |
|
| 2 | 21 Jun | 45:10 |
480 |
148 |
210 |
-10 |
5.50 km |
8'11" |
๐ช Gym
| # | Tanggal | Durasi | Kalori | Avg HR | Load | Charge | HR Zone | Sets |
|---|---|---|---|---|---|---|---|---|
| 1 | 23 Jun | 31m 43s |
130 |
86 |
180 |
-6 |
13 sets |
|
| 2 | 19 Jun | 28m 10s |
118 |
91 |
150 |
-5 |
11 sets |
๐๏ธ Workout Terbaru
3 sesi terakhir
๐ธ
Badminton 27 Jun
4.5
TE
๐
Lari 28 Jun
2.3
TE
๐ช
Gym 23 Jun
Upper
Fokus
๐ก Insight & Reminder
Analisis kondisi tubuh berbasis data 7โ30 hari terakhir
-
/ 100
Kondisi Hari Ini: -
Belum ada cukup data โ isi Data Pagi & Data Harian dulu di Daily Welcome Hub / Daily Input.
Recovery
- ยท Belum ada data
Aktivitas
- ยท Belum ada data
Tidur
- ยท Belum ada data
Workout
- ยท Belum ada data
Ringkasan Per Kategori
โค๏ธ Recovery
-
HRV
-
-
RHR
-
-
Fatigue Score
-
-
HybridCharge
-
-
Belum ada cukup data Recovery.
โก Aktivitas
-
Steps Hari Ini
-
-
PAI (7-day)
-
-
Exertion
-
-
Workout 30 Hari
-
-
Belum ada cukup data Aktivitas.
๐ด Tidur
-
Durasi Tidur
-
-
Sleep Score
-
-
Deep Sleep
-
-
Defisit Tidur (7 hari)
-
-
Belum ada cukup data Tidur.
Rekomendasi Hari Ini
๐ Riwayat Harian
Semua data tersimpan, urut dari yang terbaru ยท Klik baris untuk edit
๐ Data Harian
๐ Result Analysis
| Tanggal | Hari | HC Pagi | HC Malam | Drain | Gain | RHR | HRV | Steps | PAI | Fatigue | Fitness | Tidur | Status โน๏ธ |
|---|
๐ Keterangan Status
โ Normal
Kondisi tubuh baik โ HRV & Fatigue dalam rentang sehat
โ Perhatian
Fatigue โฅ70 atau HRV <44ms โ mulai butuh recovery lebih
โ Tinggi
Fatigue โฅ74 atau HRV <41ms โ prioritaskan istirahat
๐ Tren HC Pagi vs Fatigue
Rata-rata harian per minggu, dari Riwayat Harian (Daily Log)
๐ Komparasi Mingguan
Minggu ini vs beberapa minggu sebelumnya
โ๏ธ Weight Trend
Tren berat badan & komposisi โ data dari timbangan harian Zepp
๐ฏ
Progress menuju target
Berat: - โ target - ยท sisa -
Body Fat: - โ target - ยท sisa -
Berat 0% progress
Body Fat 0% progress
Lihat Program โ
โ๏ธ
โผ 0.8kg
74.2 kg
Berat Badan
๐ฅ
โผ 0.5%
21.4 %
Body Fat
๐ช
โ
- %
Skeletal Muscle
๐
โ 0%
23.4
BMI
๐
โ2.6 kg
ฮ vs 3 Bulan Lalu
๐
16 Jun
Update Terakhir
โ
Belum cukup data buat lihat tren โ isi data Body Composition beberapa kali dulu.
Tren Berat Badan & Body Fat
3 bulan terakhir
Riwayat Penimbangan
Detail per sesi ยท klik untuk detail
| Tanggal | Berat | Body Fat | Otot | BMI | ฮ Berat |
|---|---|---|---|---|---|
| 15 Jun 2026 | 75.0 kg | 21.9% | 57.8 kg | 23.6 | โผ 0.4 |
| 1 Jun 2026 | 75.4 kg | 22.3% | 57.5 kg | 23.7 | โผ 0.7 |
| 18 Mei 2026 | 76.1 kg | 22.8% | 57.0 kg | 23.9 | โผ 0.7 |
| 4 Mei 2026 | 76.8 kg | 23.5% | 56.6 kg | 24.1 | โผ 0.8 |
| 20 Apr 2026 | 77.6 kg | 24.1% | 56.2 kg | 24.4 | โ |
๐งฌ Body Analysis
Komposisi tubuh (2 mingguan) ยท Body Measurement (bulanan) โ MovingLife Smart Scale
๐ด Belum Terhubung
๐ฏ Program
๐ Analysis
๐ Measurements
๐ History
๐ฏ Cutting Q3 2026
1 Jul 2026 โ 30 Sep 2026 ยท Minggu ke-4 dari 13
โณ 65 hari lagi
Weight
72.7โ 68.0 kg
Body Fat
23.4%โ 18.0%
Skeletal Muscle
43.2%โ 46.0%
BMI
22.9โ 21.5
Visceral Fat
6โ 4
โผ Riwayat Program Sebelumnya (0)
Belum ada riwayat program
Arie Rayana
-
BODY SCORE
๐ Body Composition Analysis
Measurement vs Normal Range (rentang standar dewasa, belum dipersonalisasi per tinggi/usia)
| Item | Measurement | Valuation | Normal Range |
|---|---|---|---|
| Belum ada data | |||
โ๏ธ Weight Management
Target penyesuaian
| Belum ada data |
๐ฅ Other Indicators
| Belum ada data |
๐ช Muscle Fat Analysis
Posisi nilai dalam rentang Low โ Standard โ High
Belum ada data
๐ Obesity Analysis
BMI, Body Fat %, Visceral Fat Index
Belum ada data
Visceral Fat Index
- (normal: 1โ9)
๐ง Body Type Analysis
Klasifikasi berdasarkan kombinasi BMI & Body Fat
Masked Obesity
Slightly Chubby
Obesity
Slightly Lean
Standard
Strong
Too Lean
Active
Muscular
๐งโโ๏ธ Segmental Fat Analysis
Distribusi lemak per bagian tubuh
Belum ada data
๐ง
Standard range: 80% โ 160%
โ๏ธ Muscle Balance
Distribusi massa otot per bagian tubuh
Belum ada data
๐ง
Standard: lengan/kaki 80โ120%, trunk 90โ110%
๐ก Health Advice
-
BODY SCORE
Belum ada data Body Composition โ isi lewat form di bawah dulu.
๐ Body Measurement ๐๏ธ Bulanan
Lingkar tubuh โ diukur pakai meteran, terpisah dari timbangan
31 cm
Lengan (Normal)
โฒ 0.5cm
35 cm
Lengan (Kontraksi)
โฒ 0.8cm
88 cm
Lingkar Perut
โผ 1.2cm
98 cm
Lingkar Dada
โฒ 0.3cm
56 cm
Lingkar Paha
โผ 0.4cm
โ๏ธ Input Pengukuran Bulan Ini
๐ Tren Body Score & Weight
Per sesi pengukuran (per ~2 minggu)
๐ Riwayat Pengukuran
Body Score & indikator utama
| Tanggal | Score | Weight | Body Fat | BMI | Visceral Fat | Skeletal Muscle |
|---|---|---|---|---|---|---|
| 29 Jun 2026 | 76 | 72.7 kg | 23.4% | 22.9 | 6 | 43.2% |
| 15 Jun 2026 | 74 | 73.1 kg | 23.8% | 23.1 | 6 | 43.0% |
| 1 Jun 2026 | 72 | 73.6 kg | 24.2% | 23.3 | 7 | 42.8% |
| 18 Mei 2026 | 70 | 74.0 kg | 24.7% | 23.5 | 7 | 42.6% |
| 4 Mei 2026 | 68 | 74.5 kg | 25.2% | 23.7 | 7 | 42.3% |
๐ค Sick Log
Riwayat & analisa hari sakit โ kondisi, obat, dan tren dari waktu ke waktu
๐ Belum ada hari sakit tercatat dalam periode ini.
๐ฉบ Analisa & Rekomendasi
-
๐ Riwayat Hari Sakit
-
| Tanggal | Durasi | Kondisi / Diagnosa | Obat & Dosis |
|---|
๐ Tren Bulanan
Jumlah hari sakit per bulan โ 6 bulan terakhir
๐๏ธ Workout
Badminton ยท Lari ยท Gym โ catat hasil tiap sesi dari Zepp App
๐ข Terhubung ke Sheets
๐ธ Badminton
๐ Lari
๐ช Gym
-
Sesi / Minggu
-
Rataยฒ Durasi
-
Rataยฒ Kalori
-
Rataยฒ Avg HR
-
Rataยฒ Training Load
-
Rataยฒ Training Effect
๐ฏ Target Performa Badminton
โ๏ธ Edit Target
Training Effect โ Aerobic
-โ4.5
Training Effect โ Anaerobic
-โ4.7
โน๏ธ "Cur" dihitung dari rata-rata 5 sesi terakhir yang punya data Training Effect. Target spesifik lain (selain training effect) belum ditentukan โ bisa ditambah nanti kalau sudah ada metrik yang relevan.
๐ฏ Rata-rata Zona Latihan
Belum ada data
Riwayat Sesi Terbaru
3 sesi terakhir
Training Load โ 8 Sesi Terakhir
Badminton
๐ Riwayat Sesi Badminton
| Tanggal | Durasi | Kalori | Avg HR | Max HR | Load | TE Aero | TE Anaero | Balance | RPE |
|---|---|---|---|---|---|---|---|---|---|
| 27 Jun 07:15 | 1j 45m | 1.205 | 147 | 180 | 314 | 4.4 | 4.5 | 65/35 | 8 |
| 24 Jun 19:30 | 1j 22m | 980 | 139 | 172 | 270 | 4.0 | 3.9 | 63/37 | 7 |
| 20 Jun 07:00 | 1j 50m | 1.260 | 151 | 182 | 320 | 4.5 | 4.6 | 66/34 | 9 |
| 18 Jun | 1j 35m | 1.080 | 140 | 172 | 278 | 4.0 | 3.9 | 62/38 | 7 |
| 15 Jun | 1j 40m | 1.150 | 143 | 176 | 290 | 4.2 | 4.0 | 63/37 | 7 |
| 11 Jun | 1j 50m | 1.260 | 151 | 182 | 320 | 4.5 | 4.6 | 66/34 | 9 |
| 8 Jun | 1j 45m | 1.205 | 147 | 180 | 314 | 4.4 | 4.5 | 65/35 | 8 |
| 4 Jun | 1j 42m | 1.180 | 145 | 178 | 295 | 4.3 | 4.2 | 63/37 | 7 |
| 1 Jun | 1j 38m | 1.120 | 142 | 175 | 285 | 4.2 | 4.1 | 62/38 | 7 |
Belum ada data
-
Sesi / Minggu
-
Rataยฒ Jarak
-
Rataยฒ Pace
-
Rataยฒ Avg HR
-
PR 3K
-
PR 5K
๐ฏ Target Performa Lari
โ๏ธ Edit Target
VOโ Max
-โ48.0
Training Effect โ Aerobic
-โ3.5
Training Effect โ Anaerobic
-โ1.5
โน๏ธ "Cur" dihitung dari rata-rata 5 sesi terakhir. VOโ Max belum ada di data Zepp yang dibaca app ini, jadi tetap "-" sampai field itu diisi manual.
๐ฏ Rata-rata Zona Latihan
Belum ada data
Riwayat Sesi Terbaru
3 sesi terakhir
Tren Pace โ 8 Sesi Terakhir
Lari, semakin rendah semakin cepat
๐ Riwayat Sesi Lari
| Tanggal | Jarak | Waktu | Avg Pace | Kalori | Avg HR | Cadence | TE Aero | TE Anaero | PR |
|---|---|---|---|---|---|---|---|---|---|
| 28 Jun 07:37 | 5.01 km | 43:23 | 8'39"/km | 456 | 139 | 152 | 2.3 | 0.3 | โ |
| 26 Jun | 4.20 km | 38:30 | 9'10"/km | 388 | 132 | 149 | 1.8 | 0.1 | โ |
| 23 Jun | 5.01 km | 43:23 | 8'39"/km | 456 | 139 | 152 | 2.3 | 0.3 | โ |
| 17 Jun | 4.50 km | 39:50 | 8'51"/km | 410 | 135 | 151 | 2.0 | 0.2 | โ |
| 13 Jun | 5.50 km | 45:02 | 8'11"/km | 480 | 141 | 154 | 2.4 | 0.3 | PR 5K |
| 10 Jun | 4.20 km | 38:10 | 9'05"/km | 390 | 133 | 148 | 1.9 | 0.1 | โ |
| 6 Jun | Terlewat โ hujan deras | โ | |||||||
| 3 Jun | 4.80 km | 41:32 | 8'39"/km | 432 | 136 | 150 | 2.1 | 0.2 | โ |
Belum ada data
-
Sesi / Minggu
-
Rataยฒ Durasi
-
Rataยฒ Kalori
-
Rataยฒ Avg HR
-
Rataยฒ Jumlah Set
-
Fokus Terbanyak
๐ฏ Rata-rata Zona Latihan
Belum ada data
Riwayat Sesi Terbaru
3 sesi terakhir
Muscle Group Frequency
30 hari terakhir
๐ Riwayat Sesi Gym
| Tanggal | Durasi | Sets | Kalori | Avg HR | Max HR | Fokus Otot | Balance |
|---|---|---|---|---|---|---|---|
| 23 Jun 06:33 | 31m 43s | 13 | 130 | 86 | 101 | Biceps ยท Shoulder ยท Chest | 30/70 |
| 19 Jun 06:40 | 28m 10s | 11 | 118 | 91 | 106 | Biceps ยท Triceps ยท Shoulder | 31/69 |
| 16 Jun | 30m 55s | 11 | 122 | 85 | 99 | Shoulder ยท Triceps ยท Biceps | 30/70 |
| 12 Jun | Terlewat โ jadwal berubah | โ | |||||
| 9 Jun | 31m 20s | 12 | 128 | 86 | 100 | Chest ยท Shoulder ยท Biceps | 29/71 |
| 5 Jun | 29m 45s | 10 | 118 | 88 | 103 | Biceps ยท Triceps ยท Shoulder | 30/70 |
| 2 Jun | 32m 10s | 11 | 125 | 84 | 98 | Biceps ยท Shoulder ยท Chest | 28/72 |
Belum ada data
๐ฌ Science Sport
Analisa mendalam indikator & statistik latihan per olahraga
๐ธ Badminton
๐ Lari
๐ช Gym
โค๏ธ Cardio & Load
-Training Load-
-
Avg Heart Rate-
-
Max Heart Rate-
-
Kalori / Sesi-
-
Belum ada cukup data sesi Badminton.
๐ฏ Training Effect
-Aerobic Effect-
-
Anaerobic Effect-
-
Workout Balance-
-
Sesi / Minggu-
-
Belum ada cukup data sesi Badminton.
๐ HR Zone Distribution
Rasio WaktuBelum ada data zona HR.
Belum ada cukup data.
๐ Pace & Jarak
-Avg Pace-
-
Jarak / Sesi-
-
Optimum Pace-
-
Belum ada cukup data sesi Lari.
๐ฆต Biomekanik Lari
-Avg Cadence-
-
Ground Contact Time-
-
Vertical Oscillation-
-
Belum ada cukup data sesi Lari.
๐ฏ Training Effect & PR
-Aerobic Effect-
-
Anaerobic Effect-
-
PR Terbaru-
-
Belum ada cukup data sesi Lari.
โค๏ธ Cardio & Load
-Training Load-
-
Avg Heart Rate-
-
Max Heart Rate-
-
Kalori / Sesi-
-
Belum ada cukup data sesi Lari.
๐ช Volume & Set
-Avg Sets / Sesi-
-
Durasi / Sesi-
-
Sesi / Minggu-
-
Belum ada cukup data sesi Gym.
โค๏ธ Cardio & Load
-Avg Heart Rate-
-
Training Load-
-
Kalori / Sesi-
-
Belum ada cukup data sesi Gym.
๐๏ธ Fokus Otot
-Upper Body-
Core-
Lower Body-
RPE Rata-rata-
Belum ada cukup data sesi Gym.
๐ Text Prompt
Markdown siap copy-paste ke ChatGPT / Gemini / Claude โ pilih hari atau range tanggal
๐ค Export ke AI
๐ฅ Import dari AI
1๏ธโฃ Pilih Mode Tanggal
๐
Hari Tertentu
๐ Range Tanggal
2๏ธโฃ Pilih Data yang Dimasukkan
๐ค Format untuk AI:
โ๏ธ Claude: Format dengan XML tags untuk konteks yang lebih terstruktur โ optimal untuk analisis mendalam.
๐ Preview โ 29 Juni 2026
๐ฅ Import from AI โ Backfill Data
Paste markdown dari ChatGPT / Gemini ยท hanya isi field yang kosong
Paste teks markdown yang sudah dikirim ke AI (format Daylix Summary). App akan membaca tanggal dan angka-angkanya, lalu mengisi data yang belum ada di Daylix.
๐ค AI Summary
Generate ringkasan & insight otomatis pakai Claude API โ hasil tersimpan sebagai histori di Google Sheets
1๏ธโฃ Pilih Periode
7 Hari
30 Hari
90 Hari
๐ Custom Range
๐
Periode terpilih: -
2๏ธโฃ Pilih Data yang Dianalisa
๐ Riwayat AI Summary
Klik baris untuk lihat teks lengkap
| Waktu Generate | Periode | Model | |
|---|---|---|---|
| Belum ada riwayat AI Summary | |||
โ๏ธ Settings
Google Sheets ยท Claude API ยท Reminder Targets ยท App Preferences
๐ Koneksi & Integrasi
๐ฏ Preferensi
๐ญ Demo Mode
Nonaktif โ app pakai data real dari Google Sheets
Nyalakan buat isi semua halaman (Dashboard, History, Workout, Body Analysis, Training Plan, dst) dengan data dummy โ cocok buat demo/screenshot tanpa perlu koneksi Google Sheets asli. Data yang tampil TIDAK ikut kesimpan ke Sheets selama mode ini aktif.
Koneksi Google Sheets
Hubungkan dashboard ke Google Sheets sebagai database permanen
URL ini didapat setelah deploy Google Apps Script sebagai Web App
๐ Checklist sebelum Test Koneksi:
1๏ธโฃ URL diawali
https://script.google.com/macros/s/ dan diakhiri /exec2๏ธโฃ Di Apps Script: Deploy โ Manage deployments โ Edit (โ๏ธ) โ New version
3๏ธโฃ "Execute as" = Me ยท "Who has access" = Anyone
4๏ธโฃ Copy URL dari kolom "Web app URL" (bukan Deployment ID)
โ ๏ธ Data Management
Reset koneksi Google Sheets yang tersimpan di browser ini ยท tidak mempengaruhi data yang sudah ada di Google Sheets
Ambil ulang SEMUA data dari Google Sheets dari awal (Daily Log, Body Composition, Workout, dst) โ dipakai kalau data kerasa nyangkut/gak update meski udah nunggu.
๐ค Claude API Key
๐ข Model: claude-sonnet-5
Digunakan untuk: Upload screenshot OCR (Daily Input) ยท AI Summary insight. Motivasi pagi sekarang berbasis kumpulan quote di Google Sheets, tidak pakai Claude API lagi.
Dapatkan API key di console.anthropic.com ยท Pay-as-you-go, terpisah dari Claude Pro subscription
โน๏ธ Klik "Simpan" untuk memvalidasi API key (dicek langsung ke Anthropic, gratis โ tidak ada biaya token).
โ ๏ธ API key disimpan di browser lokal (localStorage) โ tidak dikirim ke server manapun selain langsung ke api.anthropic.com. Jangan gunakan di device publik.
๐ Info Spreadsheet Terhubung
๐ Belum terhubung
Isi & simpan URL Apps Script di atas, lalu klik "Test Koneksi" untuk melihat info sheet di sini.
๐ Panduan Setup Google Sheets (sekali saja)
1
Buat Google Spreadsheet baru
Buka sheets.google.com โ buat spreadsheet baru โ beri nama
Daylix DB2
Salin Spreadsheet ID
Dari URL spreadsheet:
docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit3
Buka Apps Script Editor
Di spreadsheet โ menu Extensions โ Apps Script
4
Paste kode Apps Script
Hapus kode default, paste kode dari kotak di bawah, ganti
SPREADSHEET_ID โ klik Save5
Deploy sebagai Web App
Deploy โ New deployment โ Web app โ Execute as: Me โ Who has access: Anyone
6
Paste URL di atas โ Test Koneksi โ Simpan
Dashboard siap terhubung ke Google Sheets.
๐ Kode Google Apps Script (siap pakai)
Buka Google Sheet baru โ Extensions โ Apps Script โ hapus isi default โ paste kode ini โ Deploy โ New deployment โ Web app (Execute as: Me, Who has access: Anyone) โ copy Web app URL ke kolom di atas.
/**
* DAYLIX โ Google Apps Script Backend
* Sheet "Daily Log" dan "Body Composition" dibuat otomatis kalau belum ada.
*/
// Ambil dari URL spreadsheet: docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit
// Kalau script ini dibuat lewat menu Extensions โ Apps Script DI DALAM spreadsheet-nya,
// boleh dikosongkan โ otomatis pakai spreadsheet yang aktif.
const SPREADSHEET_ID = 'PASTE_SPREADSHEET_ID_DISINI';
const SHEET_DAILY = 'Daily Log';
const DAILY_HEADERS = ['Date','HybridCharge','RHR','HRV','Fatigue','Steps','Workout','PAI','Exertion','SleepDuration','DeepSleep','SleepScore','KondisiPagi','KondisiMalam',
'RemSleep','AwakeMinutes','AwakeCount','SleepRegularity','FitnessLevel','TrainingStatus','TotalKm','HybridChargeMalam','NapDuration','NapGain','HybridChargeDrain','HybridChargeGain',
'SickCondition','Medications'];
const SHEET_BODY = 'Body Composition';
const BODY_HEADERS = ['Date','Score','Weight','BodyFat','BMI','VisceralFat','SkeletalMuscle','BMR',
'InorganicSalt','Protein','BodyWater','Muscle',
'StandardWeight','WeightControl','FatControl','MuscleControl',
'SubcutaneousFat','LeanBodyMass','SMI',
'ArmLeftFatKg','ArmLeftFatPct','ArmRightFatKg','ArmRightFatPct','LegLeftFatKg','LegLeftFatPct','LegRightFatKg','LegRightFatPct','TrunkFatKg','TrunkFatPct',
'ArmLeftMuscleKg','ArmLeftMusclePct','ArmRightMuscleKg','ArmRightMusclePct','LegLeftMuscleKg','LegLeftMusclePct','LegRightMuscleKg','LegRightMusclePct','TrunkMuscleKg','TrunkMusclePct'];
const SHEET_WORKOUT = 'Workout Sessions';
const WORKOUT_HEADERS = ['Date','Type','StartTime','Duration','Kcal','AvgHR','MaxHR','Load','Distance','Pace','Steps','Sets','Notes',
'Balance','TEAero','TEAnae','HRZoneVO2','HRZoneAnaerobic','HRZoneAerobic','HRZoneIntensive','HRZoneLight','RPE',
'OptimumPace','AvgCadence','MaxCadence','AvgStride','MaxStride','VertOsc','GCT','PR3K','PR5K','PR10K',
'TotalSetIntervalTime','ExerciseDetail','SessionID','LapDetail','VO2Max','HybridChargeUsed'];
const SHEET_MEASUREMENT = 'Body Measurement';
const MEASUREMENT_HEADERS = ['Date','LenganNormal','LenganKontraksi','LingkarPerut','LingkarDada','LingkarPaha'];
const SHEET_AI_SUMMARY = 'AI Summary Log';
const AI_SUMMARY_HEADERS = ['Date','PeriodLabel','PeriodStart','PeriodEnd','SummaryText','Model'];
const SHEET_QUOTES = 'Motivational Quotes';
const QUOTES_HEADERS = ['Category','Text'];
const QUOTES_SEED = [
['fatigue_tinggi', 'Fatigue lagi tinggi โ bukan berarti gagal, tubuh cuma minta didengerin. Pelan-pelan aja hari ini.'],
['fatigue_tinggi', 'Capek boleh, nyerah jangan. Kurangi intensitas dikit hari ini, besok bisa gas lagi.'],
['fatigue_tinggi', 'Tubuh yang lelah itu sinyal, bukan musuh. Dengerin, jangan dilawan terus.'],
['fatigue_tinggi', 'Fatigue tinggi sekarang bukan akhir dari progress-mu โ anggap aja rem sejenak biar nanti bisa lari lebih jauh.'],
['fatigue_tinggi', 'Kalau hari ini rasanya berat, gak apa-apa. Kurangi target, yang penting tetap jalan walau pelan.'],
['fatigue_tinggi', 'Recovery itu bagian dari latihan, bukan jeda dari latihan. Istirahat hari ini investasi buat performa besok.'],
['fatigue_tinggi', 'Fatigue numpuk biasanya nunjukin kamu udah kerja keras belakangan ini. Kasih diri sendiri izin buat pulih.'],
['fatigue_tinggi', 'Satu hari santai gak bakal ngilangin progress yang udah kamu bangun. Justru bikin progress itu lebih tahan lama.'],
['fatigue_tinggi', 'Tubuh capek itu wajar โ yang penting kamu tetep dengerin sinyalnya, bukan maksa terus.'],
['fatigue_tinggi', 'Kadang kemenangan terbesar hari ini adalah berani bilang "cukup dulu" dan istirahat.'],
['fatigue_tinggi', 'Fatigue tinggi bukan tanda kamu lemah, itu tanda kamu udah push cukup keras. Sekarang giliran recovery.'],
['fatigue_tinggi', 'Coba kurangi 1 hal aja hari ini โ entah intensitas latihan atau jam begadang. Tubuh bakal berterima kasih.'],
['fatigue_tinggi', 'Kelelahan itu bukan penghalang, itu pengingat buat lebih sayang sama tubuh sendiri.'],
['fatigue_tinggi', 'Gak semua hari harus jadi hari terbaik. Hari ini cukup jadi hari yang tenang.'],
['fatigue_tinggi', 'Dengerin tubuhmu โ kalau dia bilang pelan-pelan, percaya aja. Dia yang paling tau kondisinya.'],
['hrv_rendah', 'HRV lagi turun, tanda sistem saraf lagi kerja keras buat recovery. Tidur cukup malam ini bakal ngebantu banget.'],
['hrv_rendah', 'HRV rendah bukan berarti lemah โ itu cuma minta waktu buat pulih. Kasih ruang buat tubuh reset.'],
['hrv_rendah', 'Sistem sarafmu lagi butuh napas. Coba kurangi stress & tidur lebih awal beberapa hari ini.'],
['hrv_rendah', 'HRV turun itu respons normal tubuh pas lagi capek โ cukup direspons dengan istirahat, bukan dikhawatirkan berlebihan.'],
['hrv_rendah', 'Recovery gak instan, tapi konsisten. HRV bakal naik lagi kalau kamu kasih waktu yang cukup.'],
['hrv_rendah', 'Coba napas dalam beberapa menit hari ini โ hal kecil kayak gitu bisa bantu sistem saraf lebih rileks.'],
['hrv_rendah', 'HRV rendah sering nunjukin tubuh lagi proses adaptasi. Percaya prosesnya, jangan buru-buru.'],
['hrv_rendah', 'Kalau HRV lagi di bawah biasanya, itu waktunya kurangi beban โ baik fisik maupun mental.'],
['hrv_rendah', 'Sistem pemulihanmu lagi kerja di balik layar. Kasih dia waktu tanpa gangguan tambahan.'],
['hrv_rendah', 'HRV rendah bukan alasan buat panik, itu alasan buat lebih pelan-pelan dulu.'],
['hrv_rendah', 'Recovery HRV butuh waktu, sama kayak otot butuh waktu buat pulih abis latihan berat.'],
['hrv_rendah', 'Coba kurangi kafein sore ini dan tidur 30 menit lebih awal โ langkah kecil buat HRV yang lebih baik.'],
['hrv_rendah', 'Setiap penurunan HRV itu informasi, bukan hukuman. Dengerin, terus sesuaikan ritme harianmu.'],
['hrv_rendah', 'Tubuh yang lagi mikirin banyak hal biasanya HRV-nya ikut turun. Coba kasih waktu buat diam sejenak hari ini.'],
['hrv_rendah', 'HRV bakal balik normal โ yang penting sekarang fokus ke hal yang bisa kamu kontrol: tidur, napas, dan istirahat.'],
['sleep_kurang', 'Tidur kemarin kurang, tapi hari ini masih bisa jadi hari yang baik. Coba tidur lebih awal malam ini ya.'],
['sleep_kurang', 'Kurang tidur emang bikin berat, tapi satu langkah kecil hari ini tetap berarti.'],
['sleep_kurang', 'Tidur adalah recovery paling murah dan paling efektif yang kamu punya. Jangan di-skip terus.'],
['sleep_kurang', 'Satu malam kurang tidur gak nentuin seluruh minggu. Perbaiki mulai malam ini.'],
['sleep_kurang', 'Coba matiin layar 30 menit lebih awal malam ini โ langkah kecil buat tidur yang lebih baik.'],
['sleep_kurang', 'Ngantuk hari ini wajar abis kurang tidur. Kasih diri sendiri sedikit kelonggaran, gak perlu maksa 100%.'],
['sleep_kurang', 'Tidur cukup bukan kemewahan, itu kebutuhan dasar buat performa dan mood besok.'],
['sleep_kurang', 'Kalau bisa, coba tidur siang sebentar hari ini buat nutupin sedikit defisit semalam.'],
['sleep_kurang', 'Utang tidur itu bisa dibayar โ mulai dari malam ini, prioritaskan jam tidur lebih dari biasanya.'],
['sleep_kurang', 'Kurang tidur bikin segalanya terasa lebih berat dari biasanya. Itu wajar, bukan berarti kamu gagal.'],
['sleep_kurang', 'Coba atur alarm tidur malam ini, bukan cuma alarm bangun. Kadang itu yang bikin beda.'],
['sleep_kurang', 'Tubuh yang kurang tidur butuh belas kasih, bukan tuntutan buat tetap performa maksimal.'],
['sleep_kurang', 'Satu perubahan kecil โ tidur 30 menit lebih awal โ bisa nambah energi signifikan besok pagi.'],
['sleep_kurang', 'Ngantuk itu sinyal jujur dari tubuh. Dengerin, jangan dilawan pakai kafein terus-terusan.'],
['sleep_kurang', 'Kalau hari ini berat karena kurang tidur, itu bukan kegagalan โ itu kesempatan buat benerin rutinitas malam ini.'],
['streak_membaik', 'Beberapa hari terakhir tren-nya membaik terus โ konsistensi kecil ini yang bikin hasil besar. Lanjutkan!'],
['streak_membaik', 'Progress-mu belakangan ini kelihatan jelas. Apa yang kamu lakukan sekarang ini works, jangan diubah dulu.'],
['streak_membaik', 'Tren naik ini bukan kebetulan โ ini hasil dari kebiasaan yang kamu jaga belakangan ini.'],
['streak_membaik', 'Konsistensi yang kamu bangun mulai kelihatan hasilnya. Terus aja, jangan berhenti sekarang.'],
['streak_membaik', 'Data belakangan ini nunjukin kamu di jalur yang benar. Percaya proses ini terus.'],
['streak_membaik', 'Progress kecil yang konsisten itu lebih powerful daripada lonjakan besar yang gak bertahan. Kamu lagi di jalur itu.'],
['streak_membaik', 'Tren membaik ini hasil kerja keras beberapa hari terakhir โ pantas buat dirayain sedikit.'],
['streak_membaik', 'Kamu lagi bikin momentum yang bagus. Jaga terus, sekecil apapun langkahnya.'],
['streak_membaik', 'Setiap hari yang kamu jaga konsistensinya, itu investasi buat versi dirimu yang lebih baik.'],
['streak_membaik', 'Ritme yang kamu bangun sekarang mulai berbuah. Jangan buru-buru ganti strategi, ini lagi jalan.'],
['streak_membaik', 'Progress ini bukti bahwa usaha kecil yang konsisten emang beneran ngaruh.'],
['streak_membaik', 'Tren positif ini hasil pilihan-pilihan kecil yang kamu ambil tiap hari. Keren, lanjutkan!'],
['streak_membaik', 'Kamu lagi ngebangun kebiasaan yang bakal berbuah jangka panjang. Tetap di jalur ini.'],
['streak_membaik', 'Hasil belakangan ini nunjukin: konsistensi ngalahin motivasi yang naik-turun. Terus jaga ritmenya.'],
['streak_membaik', 'Momentum bagus kayak gini sayang kalau diputus. Satu hari lagi konsisten, satu langkah lagi maju.'],
['streak_menurun', 'Beberapa hari terakhir agak menantang, tapi itu bagian dari proses. Fokus ke satu hal kecil aja hari ini.'],
['streak_menurun', 'Ada masa naik-turun, itu wajar. Yang penting tetap balik lagi besok.'],
['streak_menurun', 'Tren lagi turun bukan berarti gagal โ itu bagian normal dari perjalanan panjang.'],
['streak_menurun', 'Gak semua minggu bakal sempurna. Yang penting kamu tetap balik ke rutinitas, pelan-pelan pun gak apa.'],
['streak_menurun', 'Satu periode kurang oke gak ngehapus semua progress yang udah kamu bangun sebelumnya.'],
['streak_menurun', 'Kalau lagi ngerasa stuck, coba turunin ekspektasi hari ini jadi lebih sederhana โ cukup jaga 1 kebiasaan aja.'],
['streak_menurun', 'Penurunan ini sementara. Yang bikin beda adalah gimana kamu merespons, bukan seberapa lama turunnya.'],
['streak_menurun', 'Semua orang punya fase kayak gini. Yang penting jangan berhenti total, cukup pelan-pelan lagi.'],
['streak_menurun', 'Coba fokus ke 1 kemenangan kecil hari ini, bukan ke semua yang belum tercapai minggu ini.'],
['streak_menurun', 'Tren turun ini kesempatan buat evaluasi, bukan alasan buat nyerah.'],
['streak_menurun', 'Progress itu gak pernah linear. Fase turun kayak gini justru sering jadi awal dari comeback yang lebih kuat.'],
['streak_menurun', 'Gak apa-apa kalau beberapa hari ini berasa berat. Yang penting kamu masih di sini, masih coba.'],
['streak_menurun', 'Fokus ke proses, bukan ke angka. Angka bisa naik-turun, tapi usaha konsisten yang bakal bawa kamu maju.'],
['streak_menurun', 'Kadang tubuh & pikiran butuh fase lambat sebelum bisa gas lagi lebih kuat. Ini bagian dari itu.'],
['streak_menurun', 'Satu langkah kecil hari ini โ sekecil apapun โ udah cukup buat mutusin tren turun ini.'],
['steps_tercapai', 'Target langkah hari ini udah kecapai. Konsistensi kayak gini yang bikin progress jangka panjang.'],
['steps_tercapai', 'Steps hari ini tembus target โ kerja bagus! Aktivitas kecil kayak gini nambah ke kesehatan jangka panjang.'],
['steps_tercapai', 'Target langkah tercapai, tanda hari ini kamu gerak lebih dari cukup. Nice.'],
['steps_tercapai', 'Konsisten jalan kaki kayak gini emang gak keliatan dramatis, tapi efeknya nyata buat jantung & mood.'],
['steps_tercapai', 'Target steps hari ini beres. Aktivitas sekecil apapun tetap dihitung, dan kamu udah lakuin.'],
['steps_tercapai', 'Steps hari ini di atas target โ tubuh pasti berterima kasih atas gerakan ekstra ini.'],
['steps_tercapai', 'Target langkah tercapai lagi. Kebiasaan bagus kayak gini yang bikin beda dalam jangka panjang.'],
['steps_tercapai', 'Udah gerak cukup hari ini โ itu kemenangan kecil yang layak diapresiasi.'],
['steps_tercapai', 'Konsistensi jalan kaki harian kayak gini yang sering diremehkan, padahal efeknya besar buat kesehatan jantung.'],
['steps_tercapai', 'Target steps kecapai โ satu bukti lagi kalau kamu emang jaga rutinitas dengan baik.'],
['steps_tercapai', 'Aktivitas harian hari ini di atas target. Small wins kayak gini yang nambah tiap hari.'],
['steps_tercapai', 'Steps tercapai, energi terjaga. Terus lanjutin ritme aktivitas kayak gini.'],
['steps_tercapai', 'Target jalan kaki hari ini beres duluan โ sisa hari bisa dipake buat recovery yang lain.'],
['steps_tercapai', 'Gerak cukup hari ini artinya kamu ngasih tubuh kesempatan buat sirkulasi & mood yang lebih baik.'],
['steps_tercapai', 'Konsisten capai target steps kayak gini pelan-pelan ngebentuk kebiasaan sehat jangka panjang.'],
['workout_done_today', 'Udah gerak hari ini โ itu udah menang duluan dibanding kebanyakan orang. Nice work!'],
['workout_done_today', 'Sesi latihan hari ini beres. Tubuh bakal makin kuat tiap kali kamu tunjukin usaha kayak gini.'],
['workout_done_today', 'Latihan hari ini selesai โ satu lagi bukti komitmen kamu ke diri sendiri.'],
['workout_done_today', 'Workout hari ini bukan cuma bakar kalori, tapi juga bangun disiplin yang bakal kepake di area lain hidupmu.'],
['workout_done_today', 'Kamu udah nunjukin diri buat latihan hari ini. Itu pilihan yang gak semua orang berani ambil konsisten.'],
['workout_done_today', 'Sesi hari ini kelar. Recovery yang baik sekarang bakal bikin performa besok makin oke.'],
['workout_done_today', 'Latihan hari ini selesai โ waktunya kasih tubuh recovery yang layak, mulai dari makan & tidur cukup.'],
['workout_done_today', 'Konsistensi latihan kayak gini yang bikin perubahan nyata, bukan sesi latihan yang sesekali doang.'],
['workout_done_today', 'Workout hari ini beres โ satu langkah lagi menuju versi dirimu yang lebih kuat & sehat.'],
['workout_done_today', 'Effort hari ini di sesi latihan gak bakal sia-sia. Progress emang butuh waktu, tapi kamu lagi ke arah sana.'],
['workout_done_today', 'Udah nyempetin waktu buat latihan di tengah kesibukan โ itu sendiri udah prestasi.'],
['workout_done_today', 'Setiap sesi latihan itu deposit kecil ke kesehatan jangka panjang. Hari ini kamu baru aja nabung.'],
['workout_done_today', 'Latihan hari ini kelar โ sekarang giliran tubuh yang kerja pulihin diri. Kasih dia waktu.'],
['workout_done_today', 'Kamu konsisten muncul buat diri sendiri hari ini lewat latihan. Itu yang paling penting.'],
['workout_done_today', 'Workout selesai, satu kotak checklist lagi buat hari ini. Keep it up!'],
['general', 'Hari baru, kesempatan baru buat jadi versi terbaik dirimu.'],
['general', 'Konsisten itu lebih penting daripada sempurna. Satu langkah hari ini tetap berarti.'],
['general', 'Tubuh yang kuat dibangun dari kebiasaan kecil yang konsisten โ bukan dari satu hari yang luar biasa.'],
['general', 'Progress gak harus keliatan besar tiap hari. Yang penting arahnya tetap maju.'],
['general', 'Setiap pilihan sehat yang kamu ambil hari ini adalah investasi buat dirimu di masa depan.'],
['general', 'Gak perlu sempurna, cukup konsisten. Itu udah lebih dari cukup buat hasil jangka panjang.'],
['general', 'Hari ini adalah kesempatan baru buat dengerin tubuhmu lebih baik dari kemarin.'],
['general', 'Kemajuan kecil tetap kemajuan. Jangan remehin langkah sekecil apapun.'],
['general', 'Kamu gak perlu jadi versi sempurna hari ini, cukup jadi versi yang sedikit lebih baik dari kemarin.'],
['general', 'Rutinitas sehat yang kamu jaga sekarang bakal kerasa manfaatnya bertahun-tahun ke depan.'],
['general', 'Semua hasil besar dimulai dari kebiasaan kecil yang diulang terus-menerus. Kamu lagi di jalan itu.'],
['general', 'Dengerin tubuhmu hari ini โ dia yang paling tahu apa yang dia butuhkan.'],
['general', 'Satu keputusan sehat hari ini bisa jadi awal dari rantai kebiasaan baik berikutnya.'],
['general', 'Fokus ke proses, bukan cuma hasil akhir. Proses yang konsisten yang bakal bawa hasil.'],
['general', 'Kesehatan itu maraton, bukan sprint. Nikmati prosesnya, jangan buru-buru.'],
['general', 'Hari ini cukup jadi hari yang biasa aja โ gak semua hari harus jadi hari spesial buat tetap berarti.'],
['general', 'Kamu lebih kuat dari yang kamu kira. Buktinya, kamu masih di sini, masih coba tiap hari.'],
['general', 'Jangan bandingin progress-mu sama orang lain. Fokus ke versi dirimu kemarin, itu yang paling relevan.'],
['general', 'Setiap hari yang kamu jaga kebiasaan sehatnya, itu langkah kecil menuju hidup yang lebih baik.'],
['general', 'Istirahat itu bukan kemalasan, itu bagian dari strategi jangka panjang buat tetap konsisten.']
];
function getSpreadsheet_() {
if (SPREADSHEET_ID && SPREADSHEET_ID.indexOf('PASTE_') !== 0) {
return SpreadsheetApp.openById(SPREADSHEET_ID);
}
return SpreadsheetApp.getActiveSpreadsheet();
}
function getSheet_(name, headers) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
sheet.appendRow(headers);
} else {
// Migrasi otomatis: kalau ada kolom baru ditambahkan ke array headers lewat update kode,
// sheet lama yang udah kadung dibuat gak pernah ikut ke-update header row-nya (cuma kejadian
// sekali pas sheet baru pertama dibuat, lihat blok di atas) โ isi header yang kurang di ujung
// kanan row 1 tanpa ganggu kolom/data yang udah ada.
// PENTING: cek per-NAMA (bukan cuma banding JUMLAH kolom pakai getLastColumn()) โ getLastColumn()
// itu whole-sheet (bisa kebaca lebih besar dari jumlah header asli row 1 kalau ada sisa
// formatting/konten nyasar di kolom lain), jadi kalau dipakai buat nentuin "berapa banyak
// header yang kurang", bisa salah hitung dan header yang ditulis ulang jadi geser posisi
// (persis bug yang bikin kolom TotalKm skip kosong padahal kolom sesudahnya kefill benar).
const currentLastCol = sheet.getLastColumn();
const existingHeaders = currentLastCol > 0 ? sheet.getRange(1, 1, 1, currentLastCol).getValues()[0] : [];
const missingHeaders = headers.filter(h => existingHeaders.indexOf(h) === -1);
if (missingHeaders.length > 0) {
sheet.getRange(1, currentLastCol + 1, 1, missingHeaders.length).setValues([missingHeaders]);
}
}
return sheet;
}
// Baca header row FISIK sheet (row 1) dan petakan nama kolom โ index (0-based).
// Dipakai supaya baca/tulis data SELALU by NAMA kolom, bukan asumsi posisi array
// headers di kode = posisi kolom asli di Sheet โ soalnya 2 hal itu bisa gak sinkron
// kalau header baru pernah ditambah di TENGAH array (bukan di ujung) di suatu waktu,
// padahal sheet lama udah kadung punya urutan kolom fisik yang berbeda. Kalau
// posisi rely on array index doang, hasilnya data nyasar ke kolom yang salah
// (persis kayak bug "Jarak keisi tanggal" yang dilaporkan user).
function getHeaderIndexMap_(sheet) {
const lastCol = sheet.getLastColumn();
if (lastCol < 1) return {};
const headerRow = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
const map = {};
headerRow.forEach((h, i) => { if (h !== '') map[h] = i; });
return map;
}
// Tulis 1 baris ke sheet dengan format kolom DIPAKSA "Plain Text" (@) SEBELUM nilai ditulis.
// Alasan: field kayak Duration/HRZone yang isinya teks "MM:SS" (mis. "35:11") kalau ditulis
// ke cell yang formatnya masih "Automatic" akan di-auto-convert Google Sheets jadi Time value
// beneran (dibaca ulang jadi Date object, lalu formatCell_ format-in balik jadi "35:11:00" โ
// 3 segmen, bukan 2 โ yang bikin parser MM:SS split-box di frontend gagal match & keliatan
// kayak field-nya hilang isinya, padahal datanya ada tapi salah bentuk). Plain Text mencegah
// auto-convert ini dari awal, jadi teks yang ditulis tetap apa adanya pas dibaca lagi.
function writeRowValues_(sheet, rowIdx, rowValues) {
const range = rowIdx
? sheet.getRange(rowIdx, 1, 1, rowValues.length)
: sheet.getRange(sheet.getLastRow() + 1, 1, 1, rowValues.length);
range.setNumberFormat('@');
range.setValues([rowValues]);
}
function getDailySheet_() { return getSheet_(SHEET_DAILY, DAILY_HEADERS); }
function getBodySheet_() { return getSheet_(SHEET_BODY, BODY_HEADERS); }
function getWorkoutSheet_() { return getSheet_(SHEET_WORKOUT, WORKOUT_HEADERS); }
function getMeasurementSheet_() { return getSheet_(SHEET_MEASUREMENT, MEASUREMENT_HEADERS); }
function getAiSummarySheet_() { return getSheet_(SHEET_AI_SUMMARY, AI_SUMMARY_HEADERS); }
function getQuotesSheet_() {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName(SHEET_QUOTES);
if (!sheet) {
sheet = ss.insertSheet(SHEET_QUOTES);
sheet.appendRow(QUOTES_HEADERS);
QUOTES_SEED.forEach(row => sheet.appendRow(row));
}
return sheet;
}
function getAllQuotes_() {
const sheet = getQuotesSheet_();
const values = sheet.getDataRange().getValues();
if (values.length < 2) return [];
return values.slice(1)
.filter(r => r[0] !== '' && r[1] !== '')
.map(r => ({ Category: r[0], Text: r[1] }));
}
const SHEET_CONFIG = 'App Config';
const CONFIG_HEADERS = ['Key', 'Value'];
function getConfigSheet_() { return getSheet_(SHEET_CONFIG, CONFIG_HEADERS); }
function getAllConfig_() {
const sheet = getConfigSheet_();
const values = sheet.getDataRange().getValues();
if (values.length < 2) return {};
const obj = {};
values.slice(1).forEach(r => { if (r[0] !== '') obj[r[0]] = formatCell_(r[1]); });
return obj;
}
// Key-Value config (Reminder Targets & App Preferences) โ beda dari upsertRow_
// yang di-key by Date, ini di-key by nama setting-nya sendiri (1 baris per key).
function saveConfig_(data) {
if (!data) throw new Error('Data wajib diisi');
const sheet = getConfigSheet_();
const last = sheet.getLastRow();
const keyRows = {};
if (last >= 2) {
const values = sheet.getRange(2, 1, last - 1, 1).getValues();
values.forEach((r, i) => { if (r[0] !== '') keyRows[r[0]] = i + 2; });
}
Object.keys(data).forEach(key => {
if (keyRows[key]) {
sheet.getRange(keyRows[key], 2).setValue(data[key]);
} else {
sheet.appendRow([key, data[key]]);
}
});
return { ok: true };
}
function doGet(e) {
const action = (e.parameter.action || 'ping');
try {
if (action === 'ping') return json_({ ok: true, message: 'Daylix backend aktif' });
if (action === 'getInfo') return json_(getInfo_());
if (action === 'getData') return json_({ ok: true, rows: getAllRows_(getDailySheet_(), DAILY_HEADERS) });
if (action === 'getBodyData') return json_({ ok: true, rows: getAllRows_(getBodySheet_(), BODY_HEADERS) });
if (action === 'getWorkoutData') return json_({ ok: true, rows: getAllRows_(getWorkoutSheet_(), WORKOUT_HEADERS) });
if (action === 'getMeasurementData') return json_({ ok: true, rows: getAllRows_(getMeasurementSheet_(), MEASUREMENT_HEADERS) });
if (action === 'getQuotesData') return json_({ ok: true, rows: getAllQuotes_() });
if (action === 'getAiSummaryLog') return json_({ ok: true, rows: getAllRows_(getAiSummarySheet_(), AI_SUMMARY_HEADERS) });
if (action === 'getConfigData') return json_({ ok: true, config: getAllConfig_() });
return json_({ ok: false, error: 'Unknown action: ' + action });
} catch (err) {
return json_({ ok: false, error: String(err) });
}
}
function doPost(e) {
let body;
try {
body = JSON.parse(e.postData.contents);
} catch (err) {
return json_({ ok: false, error: 'Invalid JSON body' });
}
try {
if (body.action === 'addRow') return json_(upsertRow_(getDailySheet_(), DAILY_HEADERS, body.data));
if (body.action === 'updateRow') return json_(upsertRow_(getDailySheet_(), DAILY_HEADERS, body.data));
if (body.action === 'deleteRow') return json_(deleteRow_(getDailySheet_(), body.date));
if (body.action === 'addBodyRow') return json_(upsertRow_(getBodySheet_(), BODY_HEADERS, body.data));
if (body.action === 'updateBodyRow') return json_(upsertRow_(getBodySheet_(), BODY_HEADERS, body.data));
if (body.action === 'deleteBodyRow') return json_(deleteRow_(getBodySheet_(), body.date));
if (body.action === 'addWorkoutRow') return json_(insertWorkoutRow_(getWorkoutSheet_(), WORKOUT_HEADERS, body.data));
if (body.action === 'updateWorkoutRow') return json_(updateWorkoutRowById_(getWorkoutSheet_(), WORKOUT_HEADERS, body.data));
if (body.action === 'deleteWorkoutRow') return json_(deleteWorkoutRowById_(getWorkoutSheet_(), body.sessionId, body.date, body.type));
if (body.action === 'addMeasurementRow') return json_(upsertRow_(getMeasurementSheet_(), MEASUREMENT_HEADERS, body.data));
if (body.action === 'addAiSummaryLogRow') return json_(appendLogRow_(getAiSummarySheet_(), AI_SUMMARY_HEADERS, body.data));
if (body.action === 'saveConfigData') return json_(saveConfig_(body.data));
return json_({ ok: false, error: 'Unknown action: ' + body.action });
} catch (err) {
return json_({ ok: false, error: String(err) });
}
}
function getInfo_() {
const dailySheet = getDailySheet_();
const bodySheet = getBodySheet_();
return {
ok: true,
spreadsheetName: getSpreadsheet_().getName(),
sheetName: dailySheet.getName(),
rowCount: Math.max(0, dailySheet.getLastRow() - 1),
bodySheetName: bodySheet.getName(),
bodyRowCount: Math.max(0, bodySheet.getLastRow() - 1)
};
}
function getAllRows_(sheet, headers) {
const map = getHeaderIndexMap_(sheet);
const dateIdx = map['Date'] !== undefined ? map['Date'] : 0;
const values = sheet.getDataRange().getValues();
if (values.length < 2) return [];
return values.slice(1)
.filter(r => r[dateIdx] !== '' && r[dateIdx] !== null)
.map(r => {
const obj = {};
headers.forEach(h => { const idx = map[h]; obj[h] = idx !== undefined ? formatCell_(r[idx]) : ''; });
return obj;
})
.sort((a, b) => a.Date < b.Date ? 1 : (a.Date > b.Date ? -1 : 0));
}
function formatCell_(value) {
if (Object.prototype.toString.call(value) === '[object Date]') {
// Google Sheets auto-convert teks kolon kayak "6:12" (Durasi Tidur, StartTime, dst)
// jadi Time value โ internal-nya disimpan dengan tanggal dummy 30 Des 1899.
// Kalau dibaca balik pakai format tanggal biasa, hasilnya "1899-12-30" (bug).
// Deteksi lewat tahunnya, format sebagai jam:menit:detik alih-alih tanggal.
if (value.getFullYear() < 1900) {
return Utilities.formatDate(value, Session.getScriptTimeZone(), 'HH:mm:ss');
}
return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}
return value;
}
function findRowByDate_(sheet, isoDate) {
const last = sheet.getLastRow();
if (last < 2) return -1;
const values = sheet.getRange(2, 1, last - 1, 1).getValues();
for (let i = 0; i < values.length; i++) {
if (formatCell_(values[i][0]) === isoDate) return i + 2;
}
return -1;
}
function upsertRow_(sheet, headers, data) {
if (!data || !data.Date) throw new Error('Field Date wajib diisi');
const rowIdx = findRowByDate_(sheet, data.Date);
const map = getHeaderIndexMap_(sheet);
const lastCol = sheet.getLastColumn();
// Merge dengan baris lama supaya field yang tidak dikirim (mis. dari modal
// edit yang tidak punya semua field) tidak ikut tertimpa kosong.
let merged = data;
if (rowIdx > 0) {
const existing = sheet.getRange(rowIdx, 1, 1, lastCol).getValues()[0];
const existingObj = {};
headers.forEach(h => { const idx = map[h]; existingObj[h] = idx !== undefined ? formatCell_(existing[idx]) : ''; });
merged = Object.assign({}, existingObj, data);
}
const rowValues = new Array(lastCol).fill('');
headers.forEach(h => { const idx = map[h]; if (idx !== undefined) rowValues[idx] = merged[h] !== undefined ? merged[h] : ''; });
writeRowValues_(sheet, rowIdx > 0 ? rowIdx : null, rowValues);
return { ok: true, date: data.Date };
}
// Beda dari upsertRow_ (merge by Date, 1 baris per tanggal) โ sheet ini butuh
// APPEND murni karena bisa ada beberapa generate AI Summary di tanggal yang sama.
// Field "Date" di sini diisi timestamp lengkap (bukan cuma tanggal) dari frontend,
// jadi tetap bisa diurut kronologis biar pun beberapa entri di hari yang sama.
function appendLogRow_(sheet, headers, data) {
if (!data || !data.Date) throw new Error('Field Date wajib diisi');
const map = getHeaderIndexMap_(sheet);
const rowValues = new Array(sheet.getLastColumn()).fill('');
headers.forEach(h => { const idx = map[h]; if (idx !== undefined) rowValues[idx] = data[h] !== undefined ? data[h] : ''; });
writeRowValues_(sheet, null, rowValues);
return { ok: true, date: data.Date };
}
function deleteRow_(sheet, isoDate) {
if (!isoDate) throw new Error('Date wajib diisi');
const rowIdx = findRowByDate_(sheet, isoDate);
if (rowIdx > 0) sheet.deleteRow(rowIdx);
return { ok: true, date: isoDate };
}
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// WORKOUT SESSIONS โ beda dari upsertRow_ (1 baris per Date) karena 1 hari bisa
// punya BEBERAPA sesi (bahkan sama jenisnya). Di-key by SessionID (kolom terakhir,
// di-generate frontend), BUKAN Date, supaya sesi ke-2/ke-3 di hari yang sama gak
// saling menimpa. Baris lama (dibuat sebelum SessionID ada) di-fallback match by
// Date+Type โ begitu diedit sekali, otomatis dapat SessionID permanen.
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function findWorkoutRowById_(sheet, sessionId) {
const last = sheet.getLastRow();
if (last < 2 || !sessionId) return -1;
const map = getHeaderIndexMap_(sheet);
const idColIdx = map['SessionID'];
if (idColIdx === undefined) return -1;
const values = sheet.getRange(2, idColIdx + 1, last - 1, 1).getValues();
for (let i = 0; i < values.length; i++) {
if (values[i][0] !== '' && String(values[i][0]) === String(sessionId)) return i + 2;
}
return -1;
}
function findLegacyWorkoutRow_(sheet, isoDate, type) {
const last = sheet.getLastRow();
if (last < 2 || !isoDate || !type) return -1;
const map = getHeaderIndexMap_(sheet);
const dateColIdx = map['Date'];
const typeColIdx = map['Type'];
const idColIdx = map['SessionID'];
if (dateColIdx === undefined || typeColIdx === undefined) return -1;
const values = sheet.getRange(2, 1, last - 1, sheet.getLastColumn()).getValues();
for (let i = 0; i < values.length; i++) {
const rowDate = formatCell_(values[i][dateColIdx]);
const rowType = values[i][typeColIdx];
const rowId = idColIdx !== undefined ? values[i][idColIdx] : '';
if (rowDate === isoDate && rowType === type && !rowId) return i + 2;
}
return -1;
}
function insertWorkoutRow_(sheet, headers, data) {
if (!data || !data.Date || !data.Type) throw new Error('Field Date dan Type wajib diisi');
if (!data.SessionID) data.SessionID = Utilities.getUuid();
const map = getHeaderIndexMap_(sheet);
const rowValues = new Array(sheet.getLastColumn()).fill('');
headers.forEach(h => { const idx = map[h]; if (idx !== undefined) rowValues[idx] = data[h] !== undefined ? data[h] : ''; });
writeRowValues_(sheet, null, rowValues);
return { ok: true, date: data.Date, sessionId: data.SessionID };
}
function updateWorkoutRowById_(sheet, headers, data) {
if (!data || !data.Date || !data.Type) throw new Error('Field Date dan Type wajib diisi');
let rowIdx = data.SessionID ? findWorkoutRowById_(sheet, data.SessionID) : -1;
if (rowIdx < 0) rowIdx = findLegacyWorkoutRow_(sheet, data.Date, data.Type);
if (rowIdx < 0) return insertWorkoutRow_(sheet, headers, data);
const map = getHeaderIndexMap_(sheet);
const lastCol = sheet.getLastColumn();
const existing = sheet.getRange(rowIdx, 1, 1, lastCol).getValues()[0];
const existingObj = {};
headers.forEach(h => { const idx = map[h]; existingObj[h] = idx !== undefined ? formatCell_(existing[idx]) : ''; });
const merged = Object.assign({}, existingObj, data);
if (!merged.SessionID) merged.SessionID = Utilities.getUuid();
const rowValues = new Array(lastCol).fill('');
headers.forEach(h => { const idx = map[h]; if (idx !== undefined) rowValues[idx] = merged[h] !== undefined ? merged[h] : ''; });
writeRowValues_(sheet, rowIdx, rowValues);
return { ok: true, date: data.Date, sessionId: merged.SessionID };
}
function deleteWorkoutRowById_(sheet, sessionId, isoDate, type) {
let rowIdx = sessionId ? findWorkoutRowById_(sheet, sessionId) : -1;
if (rowIdx < 0) rowIdx = findLegacyWorkoutRow_(sheet, isoDate, type);
if (rowIdx > 0) sheet.deleteRow(rowIdx);
return { ok: true };
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
๐ฏ Reminder Targets Harian
Target ini muncul di Daily Welcome Hub sebagai reminder harian โ sesuaikan dengan kebutuhan personal
โน๏ธ Semua target di atas dipakai buat highlight warna angka (hijau/kuning/merah) di tabel Riwayat Harian โ RHR & HybridCharge/PAI di bawah otomatis diikutkan, HRV & Fatigue reuse ambang batas yang sama kayak badge Status.
๐จ App Preferences