Ciaran Connelly
About Plain Text Law Plain Text Time Tracking Archive Replies Also on Micro.blog
  • Adding iCloud Folders As Part of `getClient`

    As I mentioned, I added an ability to add iCloud folders for a new client as part of my getClient action. While this functionality involves too many interdependencies to be appropriate for sharing via the Drafts Action Directory, I’ve been asked to share how it works. So if you’d like to add this for yourself, here’s how you can do it:

    Step 1

    Create a new Drafts action. Call the action something like add folders. The action should contain a single script step, which includes just the follow script (a single function):

    function addFolders(client) {
        
        // ask if want to create folders
        let folderPrompt = Prompt.create();
        folderPrompt.title = "Folders?";
        folderPrompt.message = "Do you want to create iCloud folders and an empty comlog for " + client.nickname;
        folderPrompt.addButton("add folders");
        folderPrompt.addButton("skip making folders");
                    
        folderPrompt.show();
                    
        if (folderPrompt.buttonPressed == "add folders") {
                    
            // invoke Scriptable to create folder structure and comlog
            const baseURL = "scriptable:///run";
            var cb = CallbackURL.create();
            cb.baseURL = baseURL;
            cb.addParameter("scriptName", "new client folders and comlog");
            cb.addParameter("client", JSON.stringify(client));
                    
            let success = cb.open();
                if (!success) {
                    context.fail("Failed to create new folders");
                    console.log(cb.status);
                } 
                
        } else {
            console.log("User chose not to add folders");
        }
        
    }
    

    Step 2

    Next, you’ll need to make two modifications to your getClient action.

    First, add an “Include Action” step to the beginning of the getClient action. The action you want to include is the add folders action you created in Step 1. Make sure this step is before the main script step.

    Second, edit the main script step by adding the following at line 82 (just after the line clientList.clients.push(newClient);):

        // give option to add folders
        addFolders(newClient);
    

    (Note: You could just incorporate the function from Step 1 directly into the getClient script step. But it can make the whole script a bit unwieldy. Better to compartmentalize it as a separate action step.)

    Step 3

    Now you’ll have to go into Scriptable.

    First, go into Scriptable’s settings and add a File Bookmark for the root level of your iCloud Drive and name the bookmark iCloud Drive. (You could name it something different, but then you’ll need to adjust the script below accordingly).

    Next, create a script and call it new client folders and comlog. The content of that script should be as follows:

    // this script is intended to be called by Drafts
    // when a new client is added via the "getClient"
    // function
    //
    // It will create an iCloud directory for the client
    // with subfolders for admin, cliend documents, and working files 
    
    
    // get client and success parameters from Drafts URL
    const client = JSON.parse(args.queryParameters.client); 
    const successURL = args.queryParameters["x-success"];
    
    
    // set up basics
    const fm = FileManager.iCloud();
    const root = fm.bookmarkedPath("iCloud Drive");
    const clientDirectoryPath = root + "/• Work/clients/" + client.nickname + " — " + client.number + "/";
    
    
    // create folders
    fm.createDirectory(clientDirectoryPath + "working files/", true);  // "true" ensures creation of intermediate directories
    fm.createDirectory(clientDirectoryPath + "admin/");
    fm.createDirectory(clientDirectoryPath + "client documents/");
    
    
    // create a comlog (with an internal hashtag for 1Writer and similar)
    fm.writeString(clientDirectoryPath + "working files/" + client.nickname + " comlog.md", "\n\n#comlog");
    fm.addTag(clientDirectoryPath + "working files/" + client.nickname + " comlog.md", "comlog");
    
    
    // return to Drafts
    Safari.open(successURL);
    

    Step 4

    Cross your fingers, go back into Drafts, and use the get client number action to add a new client. After you tap “add”, you should be prompted to decide whether you want to add new folders, and, once you do, Drafts should open up Scriptable, which should add the folders, and then return you to Drafts. There may well be a few permission dialogs the first time or two that you run these scripts. But after you grant the appropriate permissions, it should all work quite smoothly.

    Please note that I’ve created the folders and files in a structure that works for me. You’ll want to mess around with the folder paths and file names to suit your own uses. If you have any issues, please feel free to reach out for help.

    Enjoy!

    → 10:27 AM, May 24
  • I expanded my getClient function today to optionally create a series of client folders in iCloud when a new client is added. This is made possible by Scriptable, which can access the full iCloud Drive file system. Hopefully, Apple will open that up in the future. But for now, Scriptable bridges the gap.

    → 5:05 PM, Mar 6
  • A few clarifications re: time tracking

    I’ve modified the timeslip script in the Drafts action directory so that time entries are consolidated based on the standardized client name rather than the client number.

    Before the change, if you didn’t use unique client numbers (or just left the number field blank), the script would think all the entries had the same number and would lump them all together under the first client name. The new version avoids this and makes using client numbers mostly optional.

    The only exception is that, if there are things you don’t want to track—like breaks or meals—you should still create a client with a 0 number. Such a client (or clients) get stripped out by the process timeslip action.

    Note: I do this with a single client called not work which has aliases for all the items I don’t want to track, like “lunch,” “break,” or “dentist.” This keeps my client list relatively clean. But you could get the same result by having multiple separate non-work clients, each with 0 as their number.

    → 3:53 PM, Feb 27
  • Time Tracking Using Plaint Text and Drafts - Part 5 (the getClient function)

    As mentioned in my last post, I made a getClient function to make my plain text time tracking a whole lot easier. The function allows me to standardize client names and retrieve other data related to those clients. Standardizing improves the plain text time tracking experience immensely:

    First, if you don’t standardize the client name, you get a lot of entries, that should really be one. For example, ABC Corp. and ABC and abc corp and abc corp. would be treated as separate clients (since the names don’t match exactly). This annoyance can be fixed in a single time slip relatively easily. But as soon as you want to calculate a total number of hours over a week or month or year, you need to make sure all the entries for a client use exactly the same client name. And remembering that name can be really hard if the last time you worked on a matter was weeks or months ago.

    Second, there’s a tension between (1) choosing a short client name that is easy to type as you record your time and (2) choosing a client name that is both distinct from other client names and descriptive enough to make sense to your assistant or future self who is trying to understand which actual client-matter an entry refers to.

    The solution is to have a database, (here just a simple JSON file) to keep track of everything. Each client can have an official name and a whole bunch of “aliases,” which don’t have to be unique, along with a number, a billable flag, and any other useful information.

    That’s what the getClient function does. And it’s got some bells and whistles too: It take a string, checks to see if it matches any alias of any client. If it matches more than one, it lets you choose which one you really meant. If there was a typo in a client alias in your time slip, it allows you to correct it, and if you have a new client, it allows you to add the new client to the JSON database.

    Once getClient knows which client you’re actually referring to, it returns the entire client object, allowing you to access the client name, billing number, or whatever other data you might want.

    I use getClient in several different actions, not just the action to process a time slip. For example, I have a keyboard button that looks up the client number for selected text (or prompts to type the text if nothing is selected). This is very helpful all those times when you need the billing number, but can’t quite remember it.

    Finally, this process allows me to define a non-trackable “client” with a billing number of zero. The aliases for that client include all the things that I don’t want to capture in my time tracking—things like “lunch”, “break”, “doctor”, “dentist”, etc. The time tracking script mentioned a couple of posts ago filters out all entries where the billing number is 0.

    Anyway, here’s the script:

    // function that takes string returns client object with matching alias
    // or prompts to correct string or add new client object if no match
    // client object includes name, nickname, number, billable, alias, and description
    
    function getClient(candidate) {
    	
    	const fileName =  "clientList.json";
    	
    	// make candidate lowercase for easy comparison
    	candidate = candidate.toLowerCase().trim();
    	
    	// load contents of file
    	myCloud = FileManager.createCloud();
    	let contents = myCloud.read("/" + fileName);
    	let clientList = JSON.parse(contents);
    	
    	// filter potential matches
    	let matches = clientList.clients.filter(c => c.alias.includes(candidate));
    	
    	switch (matches.length) {
    		
    		// if one match, return match
    		case 1:
    			return(matches[0]);
    		
    		// if no match, prompt for correction or new client
    		case 0:
    			let p = Prompt.create();
    			p.title = "no match";
    			p.message = "there was no match for " + candidate + "\n\n if there was a typo, enter corrected name. If " + candidate + " is a new client, enter name, nickname, aliases (separated by a comma) and a client-number";
    			p.addTextField("clientName", "Corrected/New Client Name", candidate, {wantsFocus:true});
    			p.addTextField("clientNickname", "Nickname (for iCloud folder)", "");
    			p.addTextField("clientAliases", "Client Aliases", "");
    			p.addTextField("clientNumber", "Client Number", "", {placeholder:"00000-0"});
    			p.addSwitch("billable", "Billable?", true);
    			p.addTextField("description", "Description", "");
    			p.addButton("Correct");
    			p.addButton("Add");
    
    
    			// display the prompt
    			let didNotCancel = p.show();
    
    
    			// if cancelled, just quit;
    			if (!didNotCancel) { 
    				context.cancel("user cancelled");
    				return;
    				
    			} else {
    			
    			// if chose to add new client, add to list and return new info
    			if (p.buttonPressed == "Add") {
    				let newClientName = p.fieldValues["clientName"];
    				newClientName = newClientName.toLowerCase().trim();
    				let newClientNickname = p.fieldValues["clientNickname"];
    				newClientNickname = newClientNickname.toLowerCase().trim();
    				let alias = p.fieldValues["clientAliases"].split(",");
    				alias = alias.map(x => x.toLowerCase().trim());
    				alias.push(newClientName);
    				alias.push(newClientNickname);
    				alias = [...new Set(alias)]; // eliminate any duplicates
    				let number = p.fieldValues["clientNumber"];
    				let bill = p.fieldValues["billable"];
    				let description = p.fieldValues["description"];
    							
    				// build a new client object
    				let newClient = {
    					name: newClientName,
    					nickname: newClientNickname,
    					number: number,
    					billable: bill,
    					alias: alias,
    					description: description
    				};
    				
    				// add new client to list
    				clientList.clients.push(newClient);
    
    				// write the updated client list to the file
    				contents = JSON.stringify(clientList);
    				let success = myCloud.write("/" + fileName, contents);
    				if (success) {alert(candidate + " added!");}
    				else {alert("error: file wasn't written");}
    				return(getClient(newClientName));
    			}
    			
    			// if corrected, re-run function with corrected candidate
    			if (p.buttonPressed == "Correct") {
    				console.log("correct selected. Now running getClient on: " + p.fieldValues["clientName"]);
    				return(getClient(p.fieldValues["clientName"]));
    			}
    			}
    
    
    		// if multiple matches, chose among them
    		default:
    			let q = Prompt.create();
    			q.title = "multiple matches";
    			q.message = "choose which client you meant:";
    			for (c in matches) {
    				q.addButton(matches[c].name);
    			}
    			q.show();
    			let choice = matches.filter(c => c.name == (q.buttonPressed));
    			return(choice[0]);
    	}
    }
    

    Note that it stores the database as clientList.json in the top level of your Drafts folder in iCloud, but you can move this elsewhere and adjust the script accordingly.

    Next, I’ll be putting together a tl;dr version of all this, with links to download the actions to the action directory.

    → 12:01 PM, Feb 12
  • Time Tracking Using Plain Text and Drafts - Part 4 (More Processing)

    To recap: we’ve turned our plain text time slip into a list of time entries, each with a date, duration, client, project, note, billing number, and billable/non-billable flag. But now we need to clean up the data.

    First, we need a function to combine entries that have the same client and project (like the entries for ABC Corp. in our example time slip).

    function combineEntries(dictList) {
    	let result = [];
    	result[0] = dictList[0];
    	for (i = 1; i < dictList.length; i++) {		let matched = false;
    		for (e in result) {
    			if (result[e].number == dictList[i].number && result[e].project == dictList[i].project) {
    				result[e].duration = Number(result[e].duration) + Number(dictList[i].duration);
    				result[e].duration = result[e].duration.toFixed(1);
    				result[e].note += " " + dictList[i].note;
    				matched = true;
    				break;
    			}
    		}
    		if (!matched) {
    			result.push(dictList[i]);
    		}
    	}
    	return result;
    }
    

    (I also have a second, simpler function—called combineEntriesForEmail—that combines entries for the same client even if they don’t have the same project. I’ll include it in the final post, but won’t bother adding it here.)

    To do this initial combining of entires, I just call this function on the entries list that we created in the last pose:

    entries = combineEntries(entries);

    Second, we’ll eliminate non-trackable entries (like lunch in the example) by filtering those out:

    entries = entries.filter(x => x.number != "0");

    And finally, we’ll sort the data so that billable client work comes before non-billable administrative work:

    entries = entries.sort((a,b) => b.billable - a.billable);

    Now that we have the data cleaned up, the next step is to make some useful strings to send elsewhere. I send my data to three primary destinations: (1) A pipe-delimited file akin to a .csv; (2) a true .csv file (so that Microsoft BI can read it without additional interference); and (3) an email to my assistant. Here’s how those are compiled:

    // build pipe-delimited text file addition
    var txtAddition = [];
    for (e in entries) {
    	txtAddition.push(entries[e].date + "|" + entries[e].duration + "|" + entries[e].client + "|" + entries[e].project + "|" + entries[e].note + "|" + entries[e].billable);
    }
    
    
    // build .csv addition
    var csvAddition = [];
    for (e in entries) {
    	csvAddition.push(entries[e].date + ',' + entries[e].duration + ',' + entries[e].client + ',' + entries[e].project + ',"' + entries[e].note.replace(/"/g,'""') + '",' + entries[e].billable);
    }
    
    
    // consolidate entries for email and build email contents
    entries = combineEntriesForEmail(entries);
    var emailContents = '';
    for (e in entries) {
    	emailContents += entries[e].duration + "\n" + entries[e].client + " (" + entries[e].number + ")\n" + entries[e].note + "\n\n"
    }
    
    // add a total for the day
    var dayTotal = 0;
    for (e in entries) {
    	dayTotal += Number(entries[e].duration);
    }
    dayTotal = dayTotal.toFixed(1);
    emailContents += "----\n" + dayTotal + " total";
    
    

    Finally, now that I have the strings I want, I define Drafts template tags so they can be used in subsequent action steps without all the coding.

    draft.setTemplateTag('slipDate', date);
    draft.setTemplateTag('emailContents', emailContents);
    draft.setTemplateTag('txtAddition', txtAddition.join('\n'));
    draft.setTemplateTag('csvAddition', csvAddition.join('\n'));
    

    This lets me use an email step and get the contents for the email just by using a [[emailContents]] tag. For our sample time slip, the email contents would look like this:

    2.7
    ABC Corp. (1111-1)
    Review lease. Review and respond to additional email question form Mr. ABC.
    
    1.9
    XYZ Co. (2222-2)
    Prepare for oral argument of upcoming motion to dismiss.
    
    3.0
    Jones (3333-3)
    Interview Ms. Jones regarding dispute with business partners. Prepare memo outlining potential claims.
    
    ----
    7.6 total
    

    Same goes for appending to cloud files—just use [[txtAddition]] or [[csvAddition]] as appropriate.

    That’s really all there is to processing the time. In the next post, I’ll explain that getClient function I mentioned before. For me, getting the getClient function turned my system from somewhat fiddly and brittle, so one that feels rock solid and doesn’t require maintenance.

    → 10:38 AM, Feb 10
  • Time Tracking Using Plain Text and Drafts - Part 3 (Processing)

    A plain text time slip is a good first step. We could simply treat it as paper and do the time calculations by hand (calculations made easier if we rounded each time using a button as described in the last post). But we can make Drafts do the work for us instead.

    Below I’ll through the steps to turn a time slip like the one below into more useful data. I’m breaking the script I use up into a lot of pieces to help explain what it does over the course of a couple posts. But I’ll share the complete script in the final post to this series.

    Here’s that time slip again:

    2020-02-07 (Friday)
    
    08:30 ABC Corp. - Review lease.
    
    10:12 XYZ Co., motion to dismiss - Prepare for oral argument of upcoming motion to dismiss.
    
    12:06 lunch
    
    Had a great lunch at new sushi place.
    
    13:00 ABC Corp. - Review and respond to addition email question from Mr. ABC.
    
    13:30 Jones, meetings - Interview Ms. Jones regarding dispute with business partners.
    
    14:48 Jones, strategy - Prepare memo outlining potential claims.
    
    16:30 ABC Corp.
    
    17:00 head home
    
    Remember to buy wine on the way home!
    

    We’ll begin by saving that time slip text into a constant:

    const timeslip = editor.getText();

    Then we’ll grab the date:

    const date = timeslip.match(/^\d\d\d\d-\d\d-\d\d/g);

    Then the times:

    const times = timeslip.match(/^\d\d\:\d\d/gm);

    Then the lines with the client, project, and description data:

    var lines = timeslip.match(/^\d\d:\d\d.*\n?/gm);
    lines.pop();
    

    (We drop the last line because it just has the time we stopped working).

    Now we need to turn those lines into more structured data—a list of entries, each with a date, a duration, a client, a project if any, and a description. The below bit of the script just loops through those lines, creating an entry for each:

    var entries = [];
    for (i = 0; i < lines.length; i++) {
    	entries[i] = {}
    	entries[i].date = date;
    	entries[i].duration = duration(times[i],times[i+1]);
    	
    	let name = lines[i].match(/.+?(?=(,|\n| - ))/g)[0].slice(6).trim(); // match beginning of line, as few as possible, to comma, new line, or space-hyphen-space
    	entries[i].client = getClient(name).name; // standardize the client name
    	
    	entries[i].project = lines[i].match(/, [\w ]+(?=( \- |\n))/g); // match from comma space until you either get a new line or an isolated hyphen 
    		if (entries[i].project === null) {
    			entries[i].project = '';
    		} else {
    			entries[i].project = entries[i].project[0].slice(2).toLowerCase().trim();
    		}
    	
    	entries[i].note = lines[i].match(/- .*/g);   // match from hyphen space forward
    		if (entries[i].note === null) {
    			entries[i].note = '';
    		}	else {
    			entries[i].note = entries[i].note[0].slice(2).trim();
    		}
    	
    	entries[i].billable = getClient(entries[i].client).billable;
    	entries[i].number = getClient(entries[i].client).number;
    }
    
    

    There are a couple of functions used in there that need to be explained.

    First, there’s the duration function, which takes two times and returns the duration between them. That function is as follows:

    // function to calculate durations from time-based strings
    function duration(start, end) {
    	var h1 = start.slice(0,2);
    	var h2 = end.slice(0,2);
    	var hours = h2 - h1;
    	var m1 = start.slice(3);
    	var m2 = end.slice(3);
    	var tenths = (m2 - m1)/60;
    	var totalTime = hours + tenths;
    	return totalTime.toFixed(1);
    }
    

    Next, there’s a getClient function that takes a client name, standardizes it, and provides data associated with that name (like a billing number and whether the matter is billable or not). This getClient function isn’t strictly necessary, but it’s very nice to have.

    At the end of all that processing we have a list called entries where each entry in the list has a date, a duration, a client, a project, a note, and (as a bonus) a billing number and a flag indicating whether the matter is billable or not.

    But we aren’t done yet. In the next post, we’ll go through this list of entries, combine entries with the same client and project, and filter out entries for things we aren’t tracking (like “lunch”), sort the entries, and turn them into useful strings to send out of Drafts.

    → 9:00 AM, Feb 8
  • Time Tracking Using Plain Text and Drafts - Part 2 (The Format)

    At its most basic level, my plain text time tracking consists of a simple, intuitive, and flexible format for typing out how I spend my time on a given day. I make the process easier with Drafts actions, but they aren’t necessary.

    Here’s an example of a plain text time slip:

    
    2020-02-07 (Friday)
    
    08:30 ABC Corp. - Review lease.
    
    10:12 XYZ Co., motion to dismiss - Prepare for oral argument of upcoming motion to dismiss.
    
    12:06 lunch
    
    Had a great lunch at new sushi place.
    
    13:00 ABC Corp. - Review and respond to addition email question from Mr. ABC.
    
    13:30 Jones, meetings - Interview Ms. Jones regarding dispute with business partners.
    
    14:48 Jones, strategy - Prepare memo outlining potential claims.
    
    16:30 ABC Corp.
    
    17:00 head home
    
    Remember to buy wine on the way home!
    
    

    This time slip has the advantage of being easily readable as is. The format has only two requirements:

    1. First, somewhere in the note (usually at the top), I need a line that starts with the date in ISO format, e.g. 2020-02-07 in the above.
    2. Second, the note includes a series of lines that start with a time, identify the client, and describe the work being done in the following format: HH:MM client name [, optional project name] - Description of work.. Everything except the time and the client is optional.

    Any lines that do not start with HH:MM are ignored. That gives me the flexibility to add whatever additional whitespace or information I may want between time entries without mucking up the eventual calculation (like the notes about sushi and wine above) My only caveats: client name and project name (if any) can’t include a , or the - (space-hyphen-space) sequence, since those are used to identify the breaks between client, project, and description.

    That’s all there is to it. It’s easy to remember, provides all the information usually required for billing, and is easily human readable.

    To make it even easier to type this format I’ve taken two additional steps:

    1. Creating a button in Drafts that inserts the current time. It’s just easier to hit that button than to type out 14:32 or whatever the time happens to be. (As a bonus, you can optionally have the insertion button round the time to the nearest tenth or quarter of an hour, depending on you billing practices.) Here’s a very basic example.
    2. Creating an action to start a timeslip on a new day. This action creates a new draft, inserts the date (with the day of the week in parentheses), a couple of line returns, and the current time. I can run that action when I get to work in the morning (or even use a shortcut to prompt me to do so).

    So far, so good. My next post will address how I use Drafts to manipulate the data in these time slips.

    → 10:37 AM, Feb 7
  • Time Tracking Using Plain Text and Drafts - Part 1 (Why)

    As an attorney, I need to track my time. For the past several years, I’ve done all my time tracking using plain text and the app Drafts. Here’s why:

    When I started practicing law, I tracked my time with pad and pencil. I noted the times I started and stopped work, and when I switched from one client or project to another. Simple. The resulting slip of paper was flexible and easy to understand. But it was not ideal. My note pad wasn’t always at hand. And I would still need to manually convert my note into time entries for my law firm’s billing software.

    When I first got an iPhone, I tried various time tracking apps. These apps generally present the user with some pre-defined set of clients and projects, have timers that you tap to start and end, allow for entry of notes and, sometimes, give you useful export options. These apps were ok, as far as they went. But they had drawbacks. Setting up new clients or projects was involved. If you forgot to switch timers, adjusting the timer in the app was difficult or at least annoying. Export options, if any, were not as flexible as I’d like. And descriptive notes were usually added in a separate interface, several taps away from a daily overview. I wasn’t satisfied.

    I’d been exploring using plain text for my personal notes. But it wasn’t until I learned about Drafts that using plain text for time tracking really started to make sense. Drafts had two key ingredients my other notes apps had lacked: The ability to add custom buttons to make typing text easy, and the ability to create custom actions to process that text and send it elsewhere.

    I’ll explain how I use plain text and Drafts to track my time in the next few posts.

    → 3:34 PM, Feb 6
  • RSS
  • JSON Feed
Mastodon