@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 +2 -0
- package/README.md +67 -0
- package/package.json +50 -0
- package/scripts/dayflow.mjs +207 -0
- package/src/dayflow-cli-auth.mjs +48 -0
- package/src/dayflow-cli.mjs +188 -0
- package/src/dayflow-data.mjs +239 -0
- package/src/gym-plan.mjs +112 -0
- package/src/supabase-data.mjs +32 -0
- package/src/task-lists.mjs +72 -0
package/.env.example
ADDED
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
|
+
}
|
package/src/gym-plan.mjs
ADDED
|
@@ -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
|
+
}
|