@gouthamjm1997/dayflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
2
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Dayflow
2
+
3
+ A personal, mobile-first web app for daily planning, focus sessions, and movement. It is a Next.js App Router project designed to be installable as a PWA. Supabase uses the official `@supabase/ssr` browser/server clients with a Next.js 16 `proxy.js` session refresher.
4
+
5
+ ## What works locally
6
+
7
+ - Plan, complete, and remove tasks in Google Tasks–style sections: create, rename, and delete lists, add tasks into the active section, move a task between sections, and collapse completed work per list.
8
+ - Forest-inspired focus: custom countdowns (1–180 minutes), stopwatch, task-linked sessions, reusable saved tags with add/remove controls, timer recovery after tab suspension/reload, editable session tags, a per-session tree collection, seven-day totals, and time-by-tag summaries.
9
+ - Log and remove workouts; see recent movement history.
10
+ - Follow the six-day gym split (Saturday optional, Sunday rest) with set/reps/kg logging, Jeff Nippard tier/video references, and per-lift progress charts.
11
+ - Data is saved in this browser while cloud settings are not configured.
12
+ - Google Calendar connection can read today's primary-calendar events and add a scheduled task as a 30-minute event.
13
+
14
+ ## Run it
15
+
16
+ Requirements: Node.js 20.9 or newer.
17
+
18
+ ```sh
19
+ npm install
20
+ npm run dev
21
+ ```
22
+
23
+ Open http://localhost:3000. Run `npm test` for the domain tests and `npm run build` for a production build.
24
+
25
+ ## Enable cross-device sync and Google Calendar
26
+
27
+ The app uses Supabase Auth/Database and Google OAuth. Provider credentials are not included in this repository; the first local run works without them, but cloud sync and Google Calendar remain inactive until configured.
28
+
29
+ 1. Create a Supabase project and run `db/setup.sql` in its SQL editor. The table has row-level security so each signed-in user can access only their own record.
30
+ 2. In Supabase Auth, enable the Google provider. In Google Cloud, enable the Calendar API and configure an OAuth web client with the redirect URI shown by Supabase. Add the local site URL `http://localhost:3000` to Supabase's allowed redirect URLs.
31
+ 3. Allow the Google OAuth scopes `openid`, `email`, `profile`, and `https://www.googleapis.com/auth/calendar.events`. Add your account as a test user if the consent screen is in testing mode.
32
+ 4. Copy `.env.example` to `.env.local` and set `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` to the project's URL and publishable/anon key. Never put a Supabase service-role key or Google client secret in this file.
33
+ 5. Restart `npm run dev`, then use **Connect Google** in the app. That single sign-in enables Supabase data sync and requests calendar access. Calendar events are read only when you choose Calendar/Refresh; a task is only written to Calendar when you press its ↗ button.
34
+
35
+ Calendar events use the primary calendar. Tasks, workout logs, and focus history sync as one private JSON record. The deployed project has Supabase Auth and database schema configured; a fresh clone still needs the setup steps above.
36
+
37
+ ## Dayflow CLI for Hermes
38
+
39
+ The local CLI can read and update the signed-in Supabase user's Dayflow record. Run `npm run dayflow -- auth login` once; finish Google sign-in in the browser. The refresh token is kept in Windows Credential Manager, not in the repository. CLI requests use the publishable key plus the user's refreshed session and remain subject to the table's RLS policies; no service-role key is used.
40
+
41
+ Examples: `npm run dayflow -- tasks list`, `npm run dayflow -- tasks list --list Work`, `npm run dayflow -- tasks add "Prepare project update" --list "Deep work"`, `npm run dayflow -- tasks complete <task-id>`, `npm run dayflow -- lists add "Errands"`, `npm run dayflow -- lists rename <list-id> --name "Home"`, `npm run dayflow -- workouts log --kind Walk --minutes 30 --date 2026-10-02`, and `npm run dayflow -- focus log --minutes 25 --tag "Deep Work"`. Focus logs accept `--tag`, `--seconds`, and `--timer-type countdown|stopwatch`. Task commands accept `--list` with either a list id or its exact name. Sign out with `npm run dayflow -- auth logout`. Sign-in needs `http://localhost:3000` to be free and allowlisted in Supabase.
42
+
43
+ ### Gym logging from the CLI
44
+
45
+ `npm run dayflow -- gym days` lists each training day with the exercise ids used by `gym log`, then log a finished session:
46
+
47
+ ```sh
48
+ npm run dayflow -- gym log --day day1-chest-arms --date 2026-10-02 \
49
+ --exercise machine-chest-press:3x10@40 --exercise preacher-curl:12@25,10@25
50
+ ```
51
+
52
+ Repeat `--exercise <exercise-id>:<spec>` once per movement. A spec is either `sets x reps @ kg` (`3x10@40`) or an explicit set list (`12@25,10@25`); leave off `@kg` for bodyweight. `gym list` shows saved sessions, and tags passed to `focus log --tag` are added to the same reusable tag list the web picker reads.
53
+
54
+ ### Install the CLI from npm
55
+
56
+ The CLI is published as the scoped package `@gouthamjm1997/dayflow` (`npm i -g @gouthamjm1997/dayflow`), which provides the `dayflow` command. It talks to your own Supabase project, so an installed copy reads its settings from the environment or a config file instead of the repository's `.env.local`:
57
+
58
+ - `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, or
59
+ - `~/.dayflow/config.json` containing `{"url": "…", "publishableKey": "…"}`; override that path with `DAYFLOW_CONFIG`.
60
+
61
+ Sign-in opens `http://localhost:3000` by default; set `DAYFLOW_REDIRECT_URL` (and allowlist it in Supabase) if that port is busy. Publishing needs your npm login and a fresh version: `npm login`, then `npm publish` from this repository.
62
+
63
+ ## Privacy / limitations
64
+
65
+ Workout notes are personal wellness data. Cloud records are protected by Supabase row-level policies. There is no medical guidance, user export, or account deletion UI. The Google Calendar permission allows reading and editing events; the app only reads today's agenda and creates events for tasks you explicitly send.
66
+
67
+ The gym plan and set history use the same local-first/signed-in cloud data record as other Dayflow data. Google Fit/Health Connect steps and calorie sync is not connected: Google's Fit APIs are scheduled for deprecation in 2026, and new developer sign-ups closed in May 2024. Health Connect access requires a separate Android integration rather than this browser app.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@gouthamjm1997/dayflow",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Dayflow CLI — plan tasks, log focus sessions, and record gym sets against your own Supabase Dayflow project.",
6
+ "type": "module",
7
+ "license": "UNLICENSED",
8
+ "bin": {
9
+ "dayflow": "scripts/dayflow.mjs"
10
+ },
11
+ "files": [
12
+ "scripts/dayflow.mjs",
13
+ "src/dayflow-cli.mjs",
14
+ "src/dayflow-cli-auth.mjs",
15
+ "src/dayflow-data.mjs",
16
+ "src/gym-plan.mjs",
17
+ "src/task-lists.mjs",
18
+ "src/supabase-data.mjs",
19
+ ".env.example",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://gitlab.com/gauthamjm007/dayflow.git"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.9"
31
+ },
32
+ "scripts": {
33
+ "dev": "next dev",
34
+ "build": "next build",
35
+ "start": "next start",
36
+ "test": "node --test",
37
+ "dayflow": "node scripts/dayflow.mjs"
38
+ },
39
+ "dependencies": {
40
+ "@napi-rs/keyring": "^2.1.0",
41
+ "@supabase/ssr": "^0.12.7",
42
+ "@supabase/supabase-js": "^2.117.2",
43
+ "next": "^16.3.8",
44
+ "react": "^19.3.0",
45
+ "react-dom": "^19.3.0"
46
+ },
47
+ "devDependencies": {
48
+ "happy-dom": "^20.14.5"
49
+ }
50
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { createServer } from 'node:http'
4
+ import { readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { dirname, join, resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { createClient } from '@supabase/supabase-js'
9
+ import { createRefreshTokenStore, exchangeDayflowCode, renewDayflowSession } from '../src/dayflow-cli-auth.mjs'
10
+ import { executeDayflowCommand, parseDayflowCommand } from '../src/dayflow-cli.mjs'
11
+
12
+ const REDIRECT_URL = process.env.DAYFLOW_REDIRECT_URL || 'http://localhost:3000'
13
+ const REDIRECT_PORT = Number(new URL(REDIRECT_URL).port) || 80
14
+ const CONFIG_PATH = process.env.DAYFLOW_CONFIG || join(homedir(), '.dayflow', 'config.json')
15
+ const tokenStore = createRefreshTokenStore()
16
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
17
+
18
+ function readSupabaseConfig() {
19
+ const values = {
20
+ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
21
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? '',
22
+ }
23
+ const fill = (source) => {
24
+ if (!source || typeof source !== 'object') return
25
+ for (const [key, alternate] of [['NEXT_PUBLIC_SUPABASE_URL', 'url'], ['NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'publishableKey']]) {
26
+ if (values[key]) continue
27
+ const value = source[key] ?? source[alternate]
28
+ if (typeof value === 'string' && value.trim()) values[key] = value.trim()
29
+ }
30
+ }
31
+ try {
32
+ fill(JSON.parse(readFileSync(CONFIG_PATH, 'utf8')))
33
+ } catch {
34
+ // The config file is optional; environment variables or a repository .env.local may supply the settings.
35
+ }
36
+ try {
37
+ const contents = readFileSync(join(repoRoot, '.env.local'), 'utf8')
38
+ const fromFile = {}
39
+ for (const line of contents.split(/\r?\n/)) {
40
+ const match = line.match(/^\s*(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY)\s*=\s*(.*)\s*$/)
41
+ if (match) fromFile[match[1]] = match[2].replace(/^['"]|['"]$/g, '')
42
+ }
43
+ fill(fromFile)
44
+ } catch {
45
+ // A published install has no repository .env.local; use the config file or environment instead.
46
+ }
47
+ if (!values.NEXT_PUBLIC_SUPABASE_URL || !values.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) {
48
+ throw new Error(`Supabase URL/publishable key not found. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, add them to ${CONFIG_PATH}, or keep them in the repository .env.local.`)
49
+ }
50
+ return { url: values.NEXT_PUBLIC_SUPABASE_URL, key: values.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }
51
+ }
52
+
53
+ function createAuthClient(config, storage) {
54
+ return createClient(config.url, config.key, {
55
+ auth: {
56
+ flowType: 'pkce',
57
+ persistSession: false,
58
+ autoRefreshToken: false,
59
+ detectSessionInUrl: false,
60
+ ...(storage ? { storage } : {}),
61
+ },
62
+ })
63
+ }
64
+
65
+ function openBrowser(url) {
66
+ const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'
67
+ const args = process.platform === 'win32' ? ['/d', '/c', 'start', '""', `"${url.replaceAll('"', '')}"`] : [url]
68
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: process.platform === 'win32' })
69
+ child.on('error', () => {})
70
+ child.unref()
71
+ }
72
+
73
+ async function login(config) {
74
+ const pkceValues = new Map()
75
+ const storage = {
76
+ async getItem(key) { return pkceValues.get(key) ?? null },
77
+ async setItem(key, value) { pkceValues.set(key, value) },
78
+ async removeItem(key) { pkceValues.delete(key) },
79
+ }
80
+ const authClient = createAuthClient(config, storage)
81
+ const { data, error } = await authClient.auth.signInWithOAuth({
82
+ provider: 'google',
83
+ options: { redirectTo: REDIRECT_URL, skipBrowserRedirect: true, scopes: 'email profile' },
84
+ })
85
+ if (error || !data?.url) throw new Error('Could not start Google sign-in. Check the Supabase Google provider settings.')
86
+
87
+ await new Promise((resolve, reject) => {
88
+ let settled = false
89
+ let signedInUser = null
90
+ let timer
91
+ const finish = (error) => {
92
+ if (settled) return
93
+ settled = true
94
+ clearTimeout(timer)
95
+ if (server.listening) server.close()
96
+ if (error) reject(error)
97
+ else resolve()
98
+ }
99
+ const server = createServer(async (request, response) => {
100
+ const callbackUrl = new URL(request.url ?? '/', REDIRECT_URL)
101
+ if (callbackUrl.pathname === '/connected' && signedInUser) {
102
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
103
+ response.end('<!doctype html><title>Dayflow CLI connected</title><p>Dayflow CLI connected. You can close this tab and return to the terminal.</p>')
104
+ finish()
105
+ return
106
+ }
107
+ const oauthError = callbackUrl.searchParams.get('error_description') ?? callbackUrl.searchParams.get('error')
108
+ if (oauthError) {
109
+ response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
110
+ response.end('Google sign-in was not completed. Return to the terminal and try again.')
111
+ finish(new Error('Google sign-in was not completed.'))
112
+ return
113
+ }
114
+ const code = callbackUrl.searchParams.get('code')
115
+ if (!code) {
116
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
117
+ response.end('Waiting for Dayflow sign-in.')
118
+ return
119
+ }
120
+ try {
121
+ const result = await exchangeDayflowCode(authClient, code, tokenStore)
122
+ signedInUser = result.userId
123
+ response.writeHead(303, { Location: '/connected' })
124
+ response.end()
125
+ } catch {
126
+ response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
127
+ response.end('Dayflow sign-in failed. Return to the terminal and try again.')
128
+ finish(new Error('Dayflow sign-in failed.'))
129
+ }
130
+ })
131
+ server.once('error', (error) => finish(error.code === 'EADDRINUSE'
132
+ ? new Error(`Port ${REDIRECT_PORT} is busy. Stop the local app server and retry dayflow auth login, or set DAYFLOW_REDIRECT_URL to a free port.`)
133
+ : error))
134
+ timer = setTimeout(() => finish(new Error('Google sign-in timed out. Run dayflow auth login again.')), 5 * 60 * 1000)
135
+ server.listen(REDIRECT_PORT, () => {
136
+ process.stdout.write('Opening Google sign-in. Complete it in the browser window.\n')
137
+ if (process.env.DAYFLOW_AUTH_MANUAL === '1') {
138
+ process.stdout.write(`Manual sign-in URL (short-lived): ${data.url}\n`)
139
+ }
140
+ openBrowser(data.url)
141
+ })
142
+ })
143
+ process.stdout.write('Dayflow CLI is signed in. The refresh token is stored in Windows Credential Manager.\n')
144
+ }
145
+
146
+ function printHelp() {
147
+ process.stdout.write([
148
+ 'Dayflow CLI',
149
+ 'Run as `dayflow <command>` when installed globally, or `npm run dayflow -- <command>` in this repository.',
150
+ ' auth login|status|logout',
151
+ ' tasks list [--list <list-id|name>]',
152
+ ' tasks add "Title" [--at "YYYY-MM-DDTHH:mm"] [--list <list-id|name>]',
153
+ ' tasks complete <task-id>',
154
+ ' tasks delete <task-id>',
155
+ ' tasks update <task-id> [--title "Title"] [--at "YYYY-MM-DDTHH:mm"] [--list <list-id|name>]',
156
+ ' lists list',
157
+ ' lists add "List name"',
158
+ ' lists rename <list-id> --name "List name"',
159
+ ' lists delete <list-id>',
160
+ ' workouts list',
161
+ ' workouts log --kind Walk --minutes 30 --date YYYY-MM-DD [--title "Title"] [--notes "..."]',
162
+ ' focus list',
163
+ ' focus log --minutes 25 [--task-id <task-id>] [--task-title "..."] [--tag <name>] [--seconds <n>] [--timer-type countdown|stopwatch]',
164
+ ' gym days',
165
+ ' gym list',
166
+ ' gym log --day <day-id> --date YYYY-MM-DD --exercise <exercise-id>:8@40 [--exercise ...]',
167
+ '',
168
+ 'Config: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,',
169
+ `or ${CONFIG_PATH} holding {"url": "...", "publishableKey": "..."} (override with DAYFLOW_CONFIG).`,
170
+ `Sign-in redirect: ${REDIRECT_URL} (override with DAYFLOW_REDIRECT_URL).`,
171
+ ].join('\n') + '\n')
172
+ }
173
+
174
+ export async function runDayflowCli(argv = process.argv.slice(2)) {
175
+ if (!argv.length || argv[0] === '--help' || argv[0] === 'help') {
176
+ printHelp()
177
+ return
178
+ }
179
+ if (argv[0] === 'auth' && argv[1] === 'logout') {
180
+ tokenStore.clear()
181
+ process.stdout.write('Dayflow CLI signed out on this PC.\n')
182
+ return
183
+ }
184
+ const config = readSupabaseConfig()
185
+ if (argv[0] === 'auth' && argv[1] === 'login') return login(config)
186
+ if (argv[0] === 'auth' && argv[1] === 'status') {
187
+ const client = createAuthClient(config)
188
+ const session = await renewDayflowSession(client, tokenStore.get())
189
+ tokenStore.set(session.refreshToken)
190
+ process.stdout.write('Dayflow CLI is authenticated and can access your account.\n')
191
+ return
192
+ }
193
+
194
+ const command = parseDayflowCommand(argv)
195
+ const authClient = createAuthClient(config)
196
+ const session = await renewDayflowSession(authClient, tokenStore.get())
197
+ tokenStore.set(session.refreshToken)
198
+ const result = await executeDayflowCommand(authClient, session.userId, command)
199
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
200
+ }
201
+
202
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
203
+ runDayflowCli().catch((error) => {
204
+ process.stderr.write(`Dayflow CLI: ${error.message}\n`)
205
+ process.exitCode = 1
206
+ })
207
+ }
@@ -0,0 +1,48 @@
1
+ import { Entry } from '@napi-rs/keyring'
2
+
3
+ export function createRefreshTokenStore(entry = new Entry('Dayflow CLI', 'supabase-refresh-token')) {
4
+ return {
5
+ get() {
6
+ try {
7
+ return entry.getPassword() || null
8
+ } catch {
9
+ return null
10
+ }
11
+ },
12
+ set(token) {
13
+ if (!token) throw new Error('Cannot store an empty Dayflow refresh token.')
14
+ entry.setPassword(token)
15
+ },
16
+ clear() {
17
+ try {
18
+ entry.deletePassword()
19
+ } catch {
20
+ return false
21
+ }
22
+ return true
23
+ },
24
+ }
25
+ }
26
+
27
+ export async function exchangeDayflowCode(client, code, tokenStore) {
28
+ if (!code) throw new Error('Google sign-in did not return an authorization code.')
29
+ const { data, error } = await client.auth.exchangeCodeForSession(code)
30
+ if (error || !data?.session?.refresh_token || !data.session.user?.id) {
31
+ throw new Error('Dayflow sign-in failed. Run dayflow auth login again.')
32
+ }
33
+ tokenStore.set(data.session.refresh_token)
34
+ return { userId: data.session.user.id }
35
+ }
36
+
37
+ export async function renewDayflowSession(client, refreshToken) {
38
+ if (!refreshToken) throw new Error('No Dayflow login is stored. Run dayflow auth login.')
39
+ const { data, error } = await client.auth.refreshSession({ refresh_token: refreshToken })
40
+ if (error || !data?.session?.access_token || !data.session.refresh_token || !data.session.user?.id) {
41
+ throw new Error('Dayflow login expired or could not be refreshed. Run dayflow auth login again.')
42
+ }
43
+ return {
44
+ accessToken: data.session.access_token,
45
+ refreshToken: data.session.refresh_token,
46
+ userId: data.session.user.id,
47
+ }
48
+ }
@@ -0,0 +1,188 @@
1
+ import { GYM_DAYS } from './gym-plan.mjs'
2
+ import { applyDayflowMutation, getDayflowPayload } from './dayflow-data.mjs'
3
+ import { resolveTaskList } from './task-lists.mjs'
4
+
5
+ const toInput = (options, mappings) => {
6
+ const input = {}
7
+ for (const [option, field] of mappings) {
8
+ if (options[option] !== undefined) input[field] = options[option]
9
+ }
10
+ return input
11
+ }
12
+
13
+ const setSpec = (reps, weight) => ({ reps, weight: weight === undefined ? 0 : Number(weight), completed: true })
14
+
15
+ export function parseExerciseSpec(entry) {
16
+ const text = String(entry ?? '').trim()
17
+ const separator = text.indexOf(':')
18
+ if (separator < 1) {
19
+ throw new Error(`Unsupported exercise "${text}". Use <exercise-id>:<reps@kg,...> or <exercise-id>:<sets>x<reps>@<kg>.`)
20
+ }
21
+ const exerciseId = text.slice(0, separator).trim()
22
+ const spec = text.slice(separator + 1).trim()
23
+ if (!exerciseId || !spec) {
24
+ throw new Error(`Unsupported exercise "${text}". Add the completed sets after the exercise id.`)
25
+ }
26
+
27
+ const preset = spec.match(/^(\d+)\s*x\s*(\d+)(?:\s*@\s*(\d+(?:\.\d+)?))?$/i)
28
+ if (preset) {
29
+ const [, setCount, reps, weight] = preset
30
+ return { exerciseId, sets: Array.from({ length: Number(setCount) }, () => setSpec(Number(reps), weight)) }
31
+ }
32
+
33
+ const sets = spec.split(',').map((piece) => {
34
+ const match = String(piece).trim().match(/^(\d+)(?:\s*@\s*(\d+(?:\.\d+)?))?$/)
35
+ if (!match) throw new Error(`Unsupported set "${String(piece).trim()}" for ${exerciseId}. Use reps or reps@kg, separated by commas.`)
36
+ return setSpec(Number(match[1]), match[2])
37
+ })
38
+ return { exerciseId, sets }
39
+ }
40
+
41
+ export function parseDayflowCommand(argv) {
42
+ const [group, action, ...rest] = Array.isArray(argv) ? argv : []
43
+ const options = {}
44
+ const positionals = []
45
+
46
+ const assign = (key, value) => {
47
+ if (options[key] === undefined) options[key] = value
48
+ else options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value]
49
+ }
50
+
51
+ for (let index = 0; index < rest.length; index += 1) {
52
+ const token = rest[index]
53
+ if (!token.startsWith('--')) {
54
+ positionals.push(token)
55
+ continue
56
+ }
57
+ const key = token.slice(2)
58
+ if (!key) throw new Error('Unsupported command: empty option name.')
59
+ const next = rest[index + 1]
60
+ if (next !== undefined && !next.startsWith('--')) {
61
+ assign(key, next)
62
+ index += 1
63
+ } else {
64
+ assign(key, true)
65
+ }
66
+ }
67
+
68
+ if (options.list === true) throw new Error('Unsupported command: --list needs a list id or name.')
69
+
70
+ if (group === 'tasks' && action === 'list') {
71
+ const list = options.list
72
+ return list === undefined ? { operation: 'list_tasks' } : { operation: 'list_tasks', input: { listId: list } }
73
+ }
74
+ if (group === 'tasks' && action === 'add') {
75
+ const title = positionals.join(' ').trim()
76
+ if (!title) throw new Error('Unsupported command: task title is required.')
77
+ return {
78
+ operation: 'add_task',
79
+ input: { title, ...toInput(options, [['at', 'scheduledAt'], ['list', 'listId']]) },
80
+ }
81
+ }
82
+ if (group === 'tasks' && action === 'complete') {
83
+ const id = positionals[0]
84
+ if (!id) throw new Error('Unsupported command: task ID is required.')
85
+ return { operation: 'complete_task', input: { id } }
86
+ }
87
+ if (group === 'tasks' && action === 'delete') {
88
+ const id = positionals[0]
89
+ if (!id) throw new Error('Unsupported command: task ID is required.')
90
+ return { operation: 'delete_task', input: { id } }
91
+ }
92
+ if (group === 'tasks' && action === 'update') {
93
+ const id = positionals[0]
94
+ if (!id) throw new Error('Unsupported command: task ID is required.')
95
+ return {
96
+ operation: 'update_task',
97
+ input: { id, ...toInput(options, [['title', 'title'], ['at', 'scheduledAt'], ['list', 'listId']]) },
98
+ }
99
+ }
100
+ if (group === 'lists' && action === 'list') return { operation: 'list_task_lists' }
101
+ if (group === 'lists' && action === 'add') {
102
+ const name = positionals.join(' ').trim()
103
+ if (!name) throw new Error('Unsupported command: list name is required.')
104
+ return { operation: 'add_task_list', input: { name } }
105
+ }
106
+ if (group === 'lists' && action === 'rename') {
107
+ const id = positionals[0]
108
+ if (!id) throw new Error('Unsupported command: list ID is required.')
109
+ if (typeof options.name !== 'string' || !options.name.trim()) throw new Error('Unsupported command: --name is required.')
110
+ return { operation: 'rename_task_list', input: { id, name: options.name } }
111
+ }
112
+ if (group === 'lists' && action === 'delete') {
113
+ const id = positionals[0]
114
+ if (!id) throw new Error('Unsupported command: list ID is required.')
115
+ return { operation: 'delete_task_list', input: { id } }
116
+ }
117
+ if (group === 'workouts' && action === 'list') return { operation: 'list_workouts' }
118
+ if (group === 'workouts' && action === 'log') {
119
+ return {
120
+ operation: 'log_workout',
121
+ input: {
122
+ ...toInput(options, [['kind', 'kind'], ['title', 'title'], ['date', 'date'], ['notes', 'notes']]),
123
+ ...(options.minutes !== undefined ? { duration: Number(options.minutes) } : {}),
124
+ },
125
+ }
126
+ }
127
+ if (group === 'focus' && action === 'list') return { operation: 'list_focus_sessions' }
128
+ if (group === 'focus' && action === 'log') {
129
+ return {
130
+ operation: 'log_focus_session',
131
+ input: {
132
+ ...toInput(options, [['task-id', 'taskId'], ['task-title', 'taskTitle'], ['completed-at', 'completedAt'], ['tag', 'tag'], ['seconds', 'seconds'], ['timer-type', 'timerType']]),
133
+ ...(options.minutes !== undefined ? { minutes: Number(options.minutes) } : {}),
134
+ },
135
+ }
136
+ }
137
+ if (group === 'gym' && action === 'days') return { operation: 'list_gym_days' }
138
+ if (group === 'gym' && action === 'list') return { operation: 'list_gym_sessions' }
139
+ if (group === 'gym' && action === 'log') {
140
+ const dayId = String(options.day ?? '').trim()
141
+ if (!dayId) throw new Error('Unsupported command: --day is required. Run `gym days` to see the day ids.')
142
+ const date = String(options.date ?? '').trim()
143
+ if (!date) throw new Error('Unsupported command: --date is required (YYYY-MM-DD).')
144
+ const entries = options.exercise === undefined ? [] : Array.isArray(options.exercise) ? options.exercise : [options.exercise]
145
+ if (!entries.length) throw new Error('Unsupported command: add at least one --exercise <exercise-id>:<sets>.')
146
+ return { operation: 'log_gym_session', input: { dayId, date, exercises: entries.map(parseExerciseSpec) } }
147
+ }
148
+
149
+ throw new Error(`Unsupported command: ${[group, action].filter(Boolean).join(' ') || '(empty)'}`)
150
+ }
151
+
152
+ export function getGymDaySummaries() {
153
+ return GYM_DAYS.map((day) => ({
154
+ id: day.id,
155
+ day: day.label,
156
+ focus: day.focus,
157
+ optional: Boolean(day.optional),
158
+ duration: day.duration,
159
+ exercises: day.exercises.map((item) => ({
160
+ id: item.id,
161
+ name: item.name,
162
+ sets: item.sets,
163
+ reps: item.reps,
164
+ tier: item.tier || null,
165
+ })),
166
+ }))
167
+ }
168
+
169
+ export async function executeDayflowCommand(client, userId, command, options) {
170
+ if (command.operation === 'list_gym_days') return getGymDaySummaries()
171
+ const listOperation = {
172
+ list_tasks: 'tasks',
173
+ list_task_lists: 'taskLists',
174
+ list_workouts: 'workouts',
175
+ list_focus_sessions: 'focusSessions',
176
+ list_gym_sessions: 'gymSessions',
177
+ }[command.operation]
178
+ if (listOperation) {
179
+ const payload = await getDayflowPayload(client, userId)
180
+ const items = payload[listOperation]
181
+ const reference = command.input?.listId
182
+ if (!reference) return items
183
+ const list = resolveTaskList(payload.taskLists, reference)
184
+ if (!list) throw new Error('Task list was not found.')
185
+ return items.filter((task) => task.listId === list.id)
186
+ }
187
+ return applyDayflowMutation(client, userId, command.operation, command.input ?? {}, options)
188
+ }
@@ -0,0 +1,239 @@
1
+ import { createCloudStore } from './supabase-data.mjs'
2
+ import { GYM_DAYS, createGymSession } from './gym-plan.mjs'
3
+ import {
4
+ attachTaskListIds,
5
+ createTaskList,
6
+ hasTaskListName,
7
+ normalizeTaskListName,
8
+ normalizeTaskLists,
9
+ resolveTaskList,
10
+ } from './task-lists.mjs'
11
+
12
+ const emptyPayload = () => ({
13
+ taskLists: [],
14
+ tasks: [],
15
+ workouts: [],
16
+ gymSessions: [],
17
+ focusSessions: [],
18
+ focusTags: [],
19
+ selectedFocusTaskId: null,
20
+ })
21
+
22
+ function normalizeFocusTags(tags) {
23
+ const cleaned = (Array.isArray(tags) ? tags : [])
24
+ .map((tag) => String(tag ?? '').trim().slice(0, 32))
25
+ .filter(Boolean)
26
+ return [...new Map(cleaned.map((tag) => [tag.toLocaleLowerCase(), tag])).values()]
27
+ .sort((a, b) => a.localeCompare(b))
28
+ }
29
+
30
+ export function normalizeDayflowPayload(payload) {
31
+ const source = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}
32
+ const focusSessions = Array.isArray(source.focusSessions) ? structuredClone(source.focusSessions) : []
33
+ const taskLists = normalizeTaskLists(source.taskLists)
34
+ return {
35
+ ...source,
36
+ taskLists,
37
+ tasks: attachTaskListIds(
38
+ Array.isArray(source.tasks) ? structuredClone(source.tasks) : [],
39
+ taskLists,
40
+ ),
41
+ workouts: Array.isArray(source.workouts) ? structuredClone(source.workouts) : [],
42
+ gymSessions: Array.isArray(source.gymSessions) ? structuredClone(source.gymSessions) : [],
43
+ focusSessions,
44
+ focusTags: normalizeFocusTags(Array.isArray(source.focusTags) ? source.focusTags : focusSessions.map((session) => session?.tag)),
45
+ selectedFocusTaskId: typeof source.selectedFocusTaskId === 'string' ? source.selectedFocusTaskId : null,
46
+ }
47
+ }
48
+
49
+ function resolveWritableTaskList(state, reference, { fallback = true } = {}) {
50
+ const value = String(reference ?? '').trim()
51
+ if (!value) {
52
+ if (!fallback) throw new Error('A task list is required.')
53
+ return state.taskLists[0]
54
+ }
55
+ const list = resolveTaskList(state.taskLists, value)
56
+ if (!list) throw new Error('Task list was not found.')
57
+ return list
58
+ }
59
+
60
+ export function mutateDayflowPayload(payload, operation, input, options = {}) {
61
+ const state = normalizeDayflowPayload(payload ?? emptyPayload())
62
+ const now = options.now ?? (() => new Date())
63
+ const makeId = options.makeId ?? (() => globalThis.crypto.randomUUID())
64
+ const createdAt = now().toISOString()
65
+
66
+ if (operation === 'add_task') {
67
+ const title = String(input.title ?? '').trim()
68
+ if (!title) throw new Error('A task title is required.')
69
+ if (title.length > 200) throw new Error('Task title must be 200 characters or fewer.')
70
+ const scheduledAt = String(input.scheduledAt ?? '').trim()
71
+ if (scheduledAt.length > 80) throw new Error('Scheduled time is too long.')
72
+ const list = resolveWritableTaskList(state, input.listId)
73
+ const task = { id: makeId(), title, scheduledAt, completed: false, listId: list.id, createdAt }
74
+ state.tasks.push(task)
75
+ return { payload: state, result: task }
76
+ }
77
+
78
+ if (operation === 'delete_task') {
79
+ const id = String(input.id ?? '').trim()
80
+ const taskIndex = state.tasks.findIndex((item) => item.id === id)
81
+ if (taskIndex < 0) throw new Error('Task was not found.')
82
+ const [task] = state.tasks.splice(taskIndex, 1)
83
+ if (state.selectedFocusTaskId === id) state.selectedFocusTaskId = null
84
+ return { payload: state, result: task }
85
+ }
86
+
87
+ if (operation === 'complete_task') {
88
+ const id = String(input.id ?? '').trim()
89
+ const task = state.tasks.find((item) => item.id === id)
90
+ if (!task) throw new Error('Task was not found.')
91
+ task.completed = true
92
+ return { payload: state, result: task }
93
+ }
94
+
95
+ if (operation === 'log_workout') {
96
+ const allowedKinds = new Set(['Strength', 'Walk', 'Run', 'Yoga', 'Cycling', 'Other'])
97
+ const kind = String(input.kind ?? '').trim()
98
+ if (!allowedKinds.has(kind)) throw new Error('Choose a supported workout activity.')
99
+ const duration = Number(input.duration)
100
+ if (!Number.isInteger(duration) || duration < 1 || duration > 600) {
101
+ throw new Error('Workout duration must be a whole number from 1 to 600 minutes.')
102
+ }
103
+ const date = String(input.date ?? '')
104
+ const parsedDate = new Date(`${date}T00:00:00.000Z`)
105
+ if (!date || Number.isNaN(parsedDate.valueOf()) || parsedDate.toISOString().slice(0, 10) !== date) {
106
+ throw new Error('Workout date must be a real date in YYYY-MM-DD format.')
107
+ }
108
+ const title = String(input.title ?? '').trim() || kind
109
+ const notes = String(input.notes ?? '').trim()
110
+ if (title.length > 70) throw new Error('Workout title must be 70 characters or fewer.')
111
+ if (notes.length > 240) throw new Error('Workout notes must be 240 characters or fewer.')
112
+ const workout = { id: makeId(), kind, title, duration, date, notes, createdAt }
113
+ state.workouts.push(workout)
114
+ return { payload: state, result: workout }
115
+ }
116
+
117
+ if (operation === 'log_gym_session') {
118
+ const dayId = String(input.dayId ?? '').trim()
119
+ const day = GYM_DAYS.find((item) => item.id === dayId)
120
+ if (!day || day.type !== 'strength') throw new Error('Choose a strength workout day.')
121
+ const planExercises = GYM_DAYS.flatMap((item) => item.exercises)
122
+ const exercises = (Array.isArray(input.exercises) ? input.exercises : []).map((item) => {
123
+ const exerciseId = String(item?.exerciseId ?? '').trim()
124
+ const planExercise = planExercises.find((entry) => entry.id === exerciseId)
125
+ if (!planExercise) throw new Error(`"${exerciseId}" is not part of the Dayflow gym plan.`)
126
+ return { exerciseId: planExercise.id, name: planExercise.name, sets: Array.isArray(item?.sets) ? item.sets : [] }
127
+ })
128
+ const session = createGymSession({ id: makeId(), dayId: day.id, date: String(input.date ?? ''), exercises })
129
+ state.gymSessions.push(session)
130
+ return { payload: state, result: session }
131
+ }
132
+
133
+ if (operation === 'log_focus_session') {
134
+ const minutes = Number(input.minutes)
135
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > 600) {
136
+ throw new Error('Focus duration must be a whole number from 1 to 600 minutes.')
137
+ }
138
+ const taskId = String(input.taskId ?? '').trim() || null
139
+ const task = taskId ? state.tasks.find((item) => item.id === taskId) : null
140
+ if (taskId && !task) throw new Error('Task was not found.')
141
+ const taskTitle = task?.title ?? (String(input.taskTitle ?? '').trim() || null)
142
+ const completedAt = input.completedAt ? new Date(input.completedAt) : now()
143
+ if (Number.isNaN(completedAt.valueOf())) throw new Error('Completion time must be a valid date.')
144
+ const session = {
145
+ taskId,
146
+ taskTitle,
147
+ minutes,
148
+ completedAt: completedAt.toISOString(),
149
+ }
150
+ const tag = String(input.tag ?? '').trim()
151
+ if (tag.length > 32) throw new Error('Focus tags must be 32 characters or fewer.')
152
+ if (tag) {
153
+ const saved = state.focusTags.find((item) => item.toLocaleLowerCase() === tag.toLocaleLowerCase()) || tag
154
+ session.tag = saved
155
+ if (!state.focusTags.includes(saved)) state.focusTags = normalizeFocusTags([...state.focusTags, saved])
156
+ }
157
+ if (input.seconds !== undefined) {
158
+ const seconds = Number(input.seconds)
159
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 36000) {
160
+ throw new Error('Focus seconds must be a whole number from 1 to 36000.')
161
+ }
162
+ session.seconds = seconds
163
+ }
164
+ if (input.timerType !== undefined) {
165
+ if (!['countdown', 'stopwatch'].includes(input.timerType)) throw new Error('Choose a supported focus timer type.')
166
+ session.timerType = input.timerType
167
+ }
168
+ state.focusSessions.push(session)
169
+ return { payload: state, result: session }
170
+ }
171
+
172
+ if (operation === 'update_task') {
173
+ const id = String(input.id ?? '').trim()
174
+ const task = state.tasks.find((item) => item.id === id)
175
+ if (!task) throw new Error('Task was not found.')
176
+ const hasTitle = input.title !== undefined
177
+ const hasSchedule = input.scheduledAt !== undefined
178
+ const hasList = input.listId !== undefined
179
+ if (!hasTitle && !hasSchedule && !hasList) throw new Error('Provide a new title, scheduled time, or task list.')
180
+ if (hasTitle) {
181
+ const title = String(input.title).trim()
182
+ if (!title) throw new Error('A task title cannot be empty.')
183
+ if (title.length > 200) throw new Error('Task title must be 200 characters or fewer.')
184
+ task.title = title
185
+ }
186
+ if (hasSchedule) {
187
+ const scheduledAt = String(input.scheduledAt ?? '').trim()
188
+ if (scheduledAt.length > 80) throw new Error('Scheduled time is too long.')
189
+ task.scheduledAt = scheduledAt
190
+ }
191
+ if (hasList) task.listId = resolveWritableTaskList(state, input.listId, { fallback: false }).id
192
+ return { payload: state, result: task }
193
+ }
194
+
195
+ if (operation === 'add_task_list') {
196
+ if (hasTaskListName(state.taskLists, input.name)) throw new Error('A list with that name already exists.')
197
+ const list = createTaskList(input.name, { makeId, now })
198
+ state.taskLists.push(list)
199
+ return { payload: state, result: list }
200
+ }
201
+
202
+ if (operation === 'rename_task_list') {
203
+ const id = String(input.id ?? '').trim()
204
+ const list = state.taskLists.find((item) => item.id === id)
205
+ if (!list) throw new Error('Task list was not found.')
206
+ if (hasTaskListName(state.taskLists, input.name, id)) throw new Error('A list with that name already exists.')
207
+ list.name = normalizeTaskListName(input.name)
208
+ return { payload: state, result: list }
209
+ }
210
+
211
+ if (operation === 'delete_task_list') {
212
+ const id = String(input.id ?? '').trim()
213
+ const listIndex = state.taskLists.findIndex((item) => item.id === id)
214
+ if (listIndex < 0) throw new Error('Task list was not found.')
215
+ if (state.taskLists.length < 2) throw new Error('Keep at least one task list.')
216
+ const [list] = state.taskLists.splice(listIndex, 1)
217
+ const removed = state.tasks.filter((task) => task.listId === list.id)
218
+ state.tasks = state.tasks.filter((task) => task.listId !== list.id)
219
+ if (removed.some((task) => task.id === state.selectedFocusTaskId)) state.selectedFocusTaskId = null
220
+ return { payload: state, result: { ...list, removedTaskCount: removed.length } }
221
+ }
222
+
223
+ throw new Error(`Unsupported Dayflow operation: ${operation}`)
224
+ }
225
+
226
+ export async function getDayflowPayload(client, userId) {
227
+ if (!userId) throw new Error('A signed-in Dayflow user is required.')
228
+ const cloudStore = createCloudStore(client)
229
+ return normalizeDayflowPayload(await cloudStore.read(userId))
230
+ }
231
+
232
+ export async function applyDayflowMutation(client, userId, operation, input, options) {
233
+ if (!userId) throw new Error('A signed-in Dayflow user is required.')
234
+ const cloudStore = createCloudStore(client)
235
+ const currentPayload = await cloudStore.read(userId)
236
+ const { payload, result } = mutateDayflowPayload(currentPayload, operation, input, options)
237
+ await cloudStore.write(userId, payload)
238
+ return result
239
+ }
@@ -0,0 +1,112 @@
1
+ const exercise = (id, name, muscles, sets, reps, rest, videoUrl = '', tier = '', tip = '') => ({ id, name, muscles, sets, reps, rest, videoUrl, tier, tip })
2
+
3
+ export const GYM_DAYS = [
4
+ {
5
+ id: 'day1-chest-arms', label: 'Monday', focus: 'Chest + Triceps + Biceps', type: 'strength', duration: '60–75 min',
6
+ groups: 'Chest · triceps · biceps', targetGroups: ['chest', 'triceps', 'biceps'],
7
+ exercises: [
8
+ exercise('machine-chest-press', 'Machine chest press', 'Chest', 3, '8–12', '2–3 min', 'https://youtu.be/fGm-ef-4PVk?t=276', 'S'),
9
+ exercise('incline-db-press', 'Incline dumbbell press', 'Upper chest · front delts', 3, '8–12', '2–3 min', 'https://youtu.be/fGm-ef-4PVk?t=256', 'A'),
10
+ exercise('seated-cable-fly', 'Seated cable pec fly', 'Chest', 2, '10–15', '1–2 min', 'https://youtu.be/fGm-ef-4PVk?t=649', 'S'),
11
+ exercise('triceps-pressdown', 'Bar cable triceps pressdown', 'Triceps · lateral/medial heads', 2, '10–15', '1–2 min', 'https://youtu.be/OpRMRhr0Ycc?t=129', 'A'),
12
+ exercise('preacher-curl', '45° preacher curl', 'Biceps', 2, '8–12', '1–2 min', 'https://youtu.be/GNO4OtYoCYk?t=265', 'S'),
13
+ ],
14
+ },
15
+ {
16
+ id: 'day2-back-deadlift', label: 'Tuesday', focus: 'Back + Deadlift + Rows', type: 'strength', duration: '60–75 min',
17
+ groups: 'Back · deadlift · rows', targetGroups: ['back', 'deadlift'],
18
+ exercises: [
19
+ exercise('deadlift', 'Barbell deadlift', 'Hip hinge · glutes · spinal erectors', 2, '4–6', '2–3 min', 'https://youtu.be/jLvqKgW-_G8?t=100', 'C tier for back', 'Included as requested for hinge/strength work; Jeff ranks it C-tier as a back hypertrophy exercise. Keep these crisp, not grinding.'),
20
+ exercise('chest-supported-row', 'Chest-supported row', 'Mid/upper back · lats', 3, '8–12', '2 min', 'https://youtu.be/jLvqKgW-_G8?t=533', 'S'),
21
+ exercise('neutral-grip-pulldown', 'Neutral-grip lat pulldown', 'Lats · upper back', 3, '8–12', '2 min', 'https://youtu.be/jLvqKgW-_G8?t=239', 'S'),
22
+ exercise('seated-cable-row', 'Seated cable row', 'Mid-back · lats', 2, '10–15', '1–2 min', 'https://youtu.be/jLvqKgW-_G8?t=562', 'S'),
23
+ ],
24
+ },
25
+ {
26
+ id: 'day3-shoulders-forearms-triceps', label: 'Wednesday', focus: 'Shoulders + Forearms + Triceps', type: 'strength', duration: '60–75 min',
27
+ groups: 'Front/side/rear delts · forearms · triceps', targetGroups: ['shoulders', 'forearms', 'triceps'],
28
+ exercises: [
29
+ exercise('dumbbell-shoulder-press', 'Seated dumbbell shoulder press', 'Front delts · shoulders', 2, '8–12', '2 min', 'https://youtu.be/SgyUoY0IZ7A?t=325', 'A'),
30
+ exercise('cable-lateral-raise', 'Cable lateral raise', 'Side delts', 3, '12–20', '1–2 min', 'https://youtu.be/SgyUoY0IZ7A?t=477', 'S+'),
31
+ exercise('reverse-cable-crossover', 'Reverse cable crossover', 'Rear delts', 2, '12–20', '1–2 min', 'https://youtu.be/SgyUoY0IZ7A?t=718', 'S'),
32
+ exercise('overhead-triceps-extension', 'Overhead cable triceps extension', 'Triceps · long head', 2, '10–15', '1–2 min', 'https://youtu.be/OpRMRhr0Ycc?t=193', 'S'),
33
+ exercise('wrist-curl', 'Dumbbell wrist curl', 'Forearm flexors', 2, '12–20', '1 min', 'https://youtu.be/MfMxT_jXcPE'),
34
+ exercise('reverse-wrist-curl', 'Dumbbell reverse wrist curl', 'Forearm extensors', 2, '12–20', '1 min', 'https://youtu.be/MfMxT_jXcPE'),
35
+ ],
36
+ },
37
+ {
38
+ id: 'day4-lower-chest-biceps', label: 'Thursday', focus: 'Lower Body + Chest + Biceps', type: 'strength', duration: '75–90 min',
39
+ groups: 'Quads · hamstrings · glutes · calves · chest · biceps', targetGroups: ['lower-body', 'chest', 'biceps'],
40
+ exercises: [
41
+ exercise('hack-squat', 'Hack squat', 'Quads · glutes', 3, '8–12', '2–3 min', 'https://youtu.be/kIXcoivzGf8?t=276', 'S+'),
42
+ exercise('romanian-deadlift', 'Romanian deadlift', 'Hamstrings · glutes', 2, '8–12', '2 min', 'https://youtu.be/3ryh7PNhz3E?t=701', 'A for glutes'),
43
+ exercise('seated-leg-curl', 'Seated leg curl', 'Hamstrings', 2, '10–15', '1–2 min', 'https://youtu.be/0a_fVS2s4Ho'),
44
+ exercise('standing-calf-raise', 'Standing calf raise', 'Calves', 2, '10–15', '1–2 min'),
45
+ exercise('incline-db-press', 'Incline dumbbell press', 'Upper chest', 2, '8–12', '2 min', 'https://youtu.be/fGm-ef-4PVk?t=256', 'A'),
46
+ exercise('seated-cable-fly', 'Seated cable pec fly', 'Chest', 2, '10–15', '1–2 min', 'https://youtu.be/fGm-ef-4PVk?t=649', 'S'),
47
+ exercise('bayesian-cable-curl', 'Face-away Bayesian cable curl', 'Biceps', 2, '10–15', '1–2 min', 'https://youtu.be/GNO4OtYoCYk?t=628', 'S+'),
48
+ ],
49
+ },
50
+ {
51
+ id: 'day5-back-shoulders', label: 'Friday', focus: 'Back + Shoulders', type: 'strength', duration: '60–75 min',
52
+ groups: 'Back · side delts · rear delts', targetGroups: ['back', 'shoulders'],
53
+ exercises: [
54
+ exercise('chest-supported-row', 'Chest-supported row', 'Mid/upper back · lats', 3, '8–12', '2 min', 'https://youtu.be/jLvqKgW-_G8?t=533', 'S'),
55
+ exercise('neutral-grip-pulldown', 'Neutral-grip lat pulldown', 'Lats · upper back', 3, '8–12', '2 min', 'https://youtu.be/jLvqKgW-_G8?t=239', 'S'),
56
+ exercise('cable-lateral-raise', 'Cable lateral raise', 'Side delts', 3, '12–20', '1–2 min', 'https://youtu.be/SgyUoY0IZ7A?t=477', 'S+'),
57
+ exercise('reverse-cable-crossover', 'Reverse cable crossover', 'Rear delts', 3, '12–20', '1–2 min', 'https://youtu.be/SgyUoY0IZ7A?t=718', 'S'),
58
+ ],
59
+ },
60
+ {
61
+ id: 'day6-lower-forearms', label: 'Saturday', focus: 'Lower Body + Forearms (Optional)', type: 'strength', duration: '60–75 min', optional: true,
62
+ groups: 'Quads · hamstrings · glutes · calves · forearms', targetGroups: ['lower-body', 'forearms'],
63
+ exercises: [
64
+ exercise('leg-press', '45° leg press', 'Quads · glutes', 3, '8–12', '2–3 min', 'https://youtu.be/kIXcoivzGf8?t=350', 'A'),
65
+ exercise('romanian-deadlift', 'Romanian deadlift', 'Hamstrings · glutes', 2, '8–12', '2 min', 'https://youtu.be/3ryh7PNhz3E?t=701', 'A for glutes'),
66
+ exercise('seated-leg-curl', 'Seated leg curl', 'Hamstrings', 2, '10–15', '1–2 min', 'https://youtu.be/0a_fVS2s4Ho'),
67
+ exercise('standing-calf-raise', 'Standing calf raise', 'Calves', 2, '10–15', '1–2 min'),
68
+ exercise('wrist-curl', 'Dumbbell wrist curl', 'Forearm flexors', 2, '12–20', '1 min', 'https://youtu.be/MfMxT_jXcPE'),
69
+ exercise('reverse-wrist-curl', 'Dumbbell reverse wrist curl', 'Forearm extensors', 2, '12–20', '1 min', 'https://youtu.be/MfMxT_jXcPE'),
70
+ ],
71
+ },
72
+ ]
73
+
74
+ const validDate = (date) => /^\d{4}-\d{2}-\d{2}$/.test(date)
75
+ && !Number.isNaN(Date.parse(`${date}T00:00:00Z`))
76
+ && new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date
77
+
78
+ export function createGymSession({ id, dayId, date, exercises }) {
79
+ if (!id) throw new Error('A gym session id is required.')
80
+ const day = GYM_DAYS.find((item) => item.id === dayId)
81
+ if (!day || day.type !== 'strength') throw new Error('Choose a strength workout day.')
82
+ if (!validDate(date)) throw new Error('Enter a valid session date.')
83
+ if (!Array.isArray(exercises)) throw new Error('Gym exercises must be a list.')
84
+
85
+ const loggedExercises = exercises.map((item) => {
86
+ if (!item?.exerciseId || !item.name || !Array.isArray(item.sets)) throw new Error('Each exercise needs a name and set list.')
87
+ const sets = item.sets.filter((set) => set.completed).map(({ reps, weight }) => {
88
+ const cleanReps = Number(reps)
89
+ const cleanWeight = Number(weight)
90
+ if (!Number.isInteger(cleanReps) || cleanReps < 1 || cleanReps > 100) throw new Error('Completed sets need reps between 1 and 100.')
91
+ if (!Number.isFinite(cleanWeight) || cleanWeight < 0 || cleanWeight > 1000) throw new Error('Enter a weight from 0 to 1000 kg.')
92
+ return { reps: cleanReps, weight: cleanWeight }
93
+ })
94
+ return sets.length ? { exerciseId: String(item.exerciseId), name: String(item.name), sets } : null
95
+ }).filter(Boolean)
96
+
97
+ if (!loggedExercises.length) throw new Error('Mark at least one completed set before saving.')
98
+ return { id: String(id), dayId, date, exercises: loggedExercises, createdAt: new Date().toISOString() }
99
+ }
100
+
101
+ export function getExerciseProgress(sessions, exerciseId) {
102
+ return sessions
103
+ .flatMap((session) => {
104
+ const sets = (session.exercises || [])
105
+ .filter((item) => item.exerciseId === exerciseId)
106
+ .flatMap((item) => item.sets || [])
107
+ if (!sets.length) return []
108
+ const best = sets.reduce((current, set) => Number(set.weight) > Number(current.weight) ? set : current)
109
+ return [{ date: session.date, bestWeight: Number(best.weight), bestReps: Number(best.reps) }]
110
+ })
111
+ .sort((a, b) => a.date.localeCompare(b.date))
112
+ }
@@ -0,0 +1,32 @@
1
+ export function getSupabaseConfig(env = {}) {
2
+ const url = String(env.NEXT_PUBLIC_SUPABASE_URL || '').trim()
3
+ const key = String(
4
+ env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
5
+ ).trim()
6
+ return url && key ? { url, key } : null
7
+ }
8
+
9
+ export function createCloudStore(client) {
10
+ if (!client) throw new Error('A Supabase client is required.')
11
+
12
+ return {
13
+ async read(userId) {
14
+ if (!userId) throw new Error('A signed-in user is required to load cloud data.')
15
+ const { data, error } = await client
16
+ .from('user_data')
17
+ .select('payload')
18
+ .eq('user_id', userId)
19
+ .maybeSingle()
20
+ if (error) throw error
21
+ return data?.payload ?? null
22
+ },
23
+
24
+ async write(userId, payload) {
25
+ if (!userId) throw new Error('A signed-in user is required to save cloud data.')
26
+ const { error } = await client
27
+ .from('user_data')
28
+ .upsert({ user_id: userId, payload, updated_at: new Date().toISOString() }, { onConflict: 'user_id' })
29
+ if (error) throw error
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,72 @@
1
+ export const DEFAULT_TASK_LIST_ID = 'list-default'
2
+ export const DEFAULT_TASK_LIST_NAME = 'My Tasks'
3
+ export const TASK_LIST_NAME_LIMIT = 60
4
+
5
+ const cleanName = (value) => String(value ?? '').trim().replace(/\s+/g, ' ')
6
+
7
+ /**
8
+ * Keeps the saved task lists in a predictable shape and guarantees at least one list,
9
+ * so every task always has a section to live in.
10
+ */
11
+ export function normalizeTaskLists(lists) {
12
+ const cleaned = (Array.isArray(lists) ? lists : [])
13
+ .filter((list) => list && typeof list === 'object')
14
+ .map((list) => ({
15
+ ...list,
16
+ id: String(list.id ?? '').trim(),
17
+ name: cleanName(list.name).slice(0, TASK_LIST_NAME_LIMIT),
18
+ }))
19
+ .filter((list) => list.id && list.name)
20
+ const unique = []
21
+ for (const list of cleaned) {
22
+ if (!unique.some((saved) => saved.id === list.id)) unique.push(list)
23
+ }
24
+ return unique.length ? unique : [{ id: DEFAULT_TASK_LIST_ID, name: DEFAULT_TASK_LIST_NAME, createdAt: '' }]
25
+ }
26
+
27
+ /** Points every task at a section that still exists, defaulting to the first list. */
28
+ export function attachTaskListIds(tasks, lists) {
29
+ const normalizedLists = normalizeTaskLists(lists)
30
+ const known = new Set(normalizedLists.map((list) => list.id))
31
+ const fallback = normalizedLists[0].id
32
+ return (Array.isArray(tasks) ? tasks : []).map((task) => {
33
+ const listId = String(task?.listId ?? '').trim()
34
+ return known.has(listId) ? task : { ...task, listId: fallback }
35
+ })
36
+ }
37
+
38
+ export function normalizeTaskListName(name) {
39
+ const listName = cleanName(name).slice(0, TASK_LIST_NAME_LIMIT)
40
+ if (!listName) throw new Error('A list name is required.')
41
+ if (cleanName(name).length > TASK_LIST_NAME_LIMIT) {
42
+ throw new Error(`List names must be ${TASK_LIST_NAME_LIMIT} characters or fewer.`)
43
+ }
44
+ return listName
45
+ }
46
+
47
+ export function createTaskList(name, options = {}) {
48
+ const makeId = options.makeId ?? (() => globalThis.crypto.randomUUID())
49
+ const now = options.now ?? (() => new Date())
50
+ return { id: makeId(), name: normalizeTaskListName(name), createdAt: now().toISOString() }
51
+ }
52
+
53
+ /** Finds a list by id or by exact (case-insensitive) name; an ambiguous name resolves to nothing. */
54
+ export function resolveTaskList(lists, reference) {
55
+ const value = String(reference ?? '').trim()
56
+ if (!value) return null
57
+ const byId = (Array.isArray(lists) ? lists : []).find((list) => list.id === value)
58
+ if (byId) return byId
59
+ const lowered = value.toLocaleLowerCase()
60
+ const matches = (Array.isArray(lists) ? lists : []).filter((list) => String(list.name).toLocaleLowerCase() === lowered)
61
+ return matches.length === 1 ? matches[0] : null
62
+ }
63
+
64
+ export function resolveTaskListId(lists, reference) {
65
+ return resolveTaskList(lists, reference)?.id ?? ''
66
+ }
67
+
68
+ export function hasTaskListName(lists, name, ignoreId = '') {
69
+ const lowered = cleanName(name).toLocaleLowerCase()
70
+ return (Array.isArray(lists) ? lists : [])
71
+ .some((list) => list.id !== ignoreId && String(list.name).toLocaleLowerCase() === lowered)
72
+ }