Scripting For Water
In this issue:
Last fall I made a decision to improve the quality of my life and began a regular exercise program with a personal trainer. My wife had been going to a gym for years a few days a week and I realized that if I wanted to stay healthy and active as I got older, I needed to begin regular exercise now.
As you might expect, eventually my trainer asked me how much water I drank. Sadly, not much. I sit at my desk with coffee in the morning, switching over to ice tea or a soda in the afternoon. I knew I had to change my behavior. I realized the best way for me would be to track my water consumption.
There is a saying that you can't improve what you don't measure, which I took to heart. Since I am in front of a PowerShell prompt all day, that seemed like a reasonable path. I didn't want the hassle of opening an Excel spreadsheet. I wanted to be able to store how much water I consumed with minimal effort. Of course, the whole point of this was to track my water intake, so I likewise wanted an easy way to view my progress.
I thought it might be useful to see how I approached the problem.
Storing Data
The first step was to decide what my water consumption data would look like. Since I am using PowerShell, I need to be thinking about objects. The object properties would be the pieces of information I would need, which frankly, weren't many. I didn't need much more than the number of ounces consumed and a date.
I mocked up an object in PowerShell.
[PSCustomObject]@{
PSTypeName = 'WaterInfo'
Date = Get-Date
Amount = 16
Comment = 'Workout'
}
I decided to add a Comment property as a way to future-proof my design. Most of the time I didn't expect to need it, but I liked the idea of having a notes section just in case. The whole point of my project is to be able to look back and see how much water I consumed in the last month, or last week. I included a typename anticipating I might create custom formatting at some point. I find it better to use a typename from the beginning, even if you don't plan on custom formatting. You might decide to add an alias or script property to the object, so define a typename up front.
A PowerShell Class
Now, I could have written a function to create the PSCustomObject, but I decided to create a PowerShell class. I figured this would give me flexibility and options for the future. By separating the object definition from the code that uses it, this would allow me to update the class without having to touch the function. I could also use the class across multiple PowerShell functions.
Class WaterInfo {
#the property defaults to the current date and time
[datetime]$Date = (Get-Date)
#amount in ounces
[int32]$Amount
[string]$Comment
#the constructor requires an amount
WaterInfo([int32]$Amount) {
$this.Amount = $Amount
}
}
The other benefit of using a class is that I can easily guarantee the object type for each property. Right now, my class doesn't have any methods. To create an instance of the object, all I need is the amount. I don't need to specify a date because it will default to the current date and time.
With the class, I can easily create an instance of the object.
PS C:\> [waterInfo]::new(8)
Date Amount Comment
---- ------ -------
3/18/2026 11:59:01 AM 8
I'll show you in a few minutes where I can use this.
Formatting Data
The next decision was where and how to store the data. My object design is simple enough that CSV is an option. I don't have nested data, and as long as I avoid commas in the commas in the comments, it is a viable option.
I could create a traditional XML file. But that gets a bit cumbersome if I need to manually edit or update the file. The same is true of using Export-cliXml and Import-Clixml. On the plus side, this would make it easy to preserve type. But it will create a larger file and I can't use the files outside of PowerShell.
Another option would have been to use a SQLite database. I could create tooling using the MySqlLite module. But this seemed like overkill for what would be a very simple table. Plus, the only thing I expect to do with the data is get a total value over some date range. I don't need a database for that.
I decided that using a JSON file was a good compromise. The file would be easy to manually edit if necessary. There would be minimal overhead, and converting from JSON preserves property types. Since I might want to update data on my desktop or laptop, I'll put the file in OneDrive so that I can access it from anywhere.
$waterInfoPath = "$env:OneDrive\Personal\waterInfo.json"
Add-Water
Now it is time to build commands. The first thing I need is something to record water consumption. This means a PowerShell function that wraps around my class to create an instance of my WaterInfo object.
I'll need a function that will accept the values, create the object, and append it to the JSON file. The function parameters can mirror the object properties. I need a reference to the JSON file. I could hard-code the variable in the script, but I hesitate to hard-code anything. I think that should be the exception rather than the rule. Instead, I can define a parameter with a default value. This gives me the option to use another file which could be handy if I every write Pester tests for my functions.
[Parameter(HelpMessage = "The Json file to store water data.")]
[ValidateNotNullOrEmpty()]
[ValidateScript({Test-Path $_},ErrorMessage = "Failed to find or validate {0}.")]
[string]$Path = $waterInfoPath
As you can see, I'm adding parameter validation on the path.
Since I know I will be appending data, I need to get the current contents of the JSON file before I process new data.
I also want to provide feedback so I know how I'm doing. For that, I need to know how much has already been consume for the current day.
[object[]]$data = Get-Content $Path | ConvertFrom-Json
#Get previous entries for today
[int32]$inToday = ($data | Where-Object {$_.Date.date -eq (Get-Date).Date} | Measure-Object amount -sum).Sum
When adding a new water amount, I can create a new instance of my class and update it as needed. I can also update today's intake and the complete data set.
#update today's intake
$inToday+=$Amount
#create a new waterInfo instance
$item = [WaterInfo]::New($Amount)
$item.Date = $Date
if ($Comment) {
$item.Comment = $comment
}
#write the result to the pipeline
$item
#update the total data set
$data += $item
> At some point, I should rewrite this to use a System.Collections.Generic.List for $data which should eke out a little more performance.
The last step is to update the JSON file.
$data | ConvertTo-Json | Out-File $Path -Encoding utf8
Technically, this means creating a new file every time I add data. This is where an actual database could be advantageous. The other potential issue is access. I'm the only person updating the file and I'm not going to be doing it simultaneously from multiple machines which means I don't have to worry about conflicts or file contention issues. When building a PowerShell tool like this with an external data source, you need to consider who will be accessing it and how. What potential problems could you encounter? This information will help guide you to the proper storage format.
Motivate Me
The last part of the function is purely personal. Technically, the function's output is my WaterInfo object. But I also wanted an indication of progress for the day. This information doesn't need to go to the pipeline. I can send it directly to the host since it is only information for me to read. Since I'm a big fan of using the pwshSpectreConsole module, I use a Switch statement.
Switch ($inToday) {
#using colors from pwshSpectreConsole
{$_ -ge 64} {
$color = "Green3 blink"
$msg = "You crushed it! Job well done."
Break
}
{$_ -ge 56} {
$color = "Cyan3"
$msg = "So close to the magic number. Keep sipping."
Break
}
{$_ -ge 48} {
$color = "Yellow3"
$msg = "Now we're talking!!"
Break
}
{$_ -ge 32} {
$color = "LightGoldenrod2_1"
$msg = "Outstanding work. Don't stop now."
Break
}
{$_ -ge 16} {
$color = "orange1"
$msg = "You're making progress."
Break
}
{$_ -ge 8} {
$color = "Magenta1"
$msg = "Keep at it, one glass at a time."
Break
}
Default {
$color = "red3"
$msg = "You have to start somewhere."
}
}
Remember, that unlike an If statement, PowerShell will run all script blocks for any match. If that is not the behavior you want, you may need to order the comparisons accordingly. You may also need to use the Break keyword. This forces PowerShell to not attempt any further comparisons after the first match.
My Switch statement is invoking a script block to compare the $InToday value. The $_ in each script block will be replaced with the value $InToday. Once this has been processed, I can display a motivational message.
Write-SpectreHost "[$color]Your current daily total is $inToday oz. $msg[/]"