This is one in a series of articles about Time Tortoise, a Universal Windows Platform app for planning and tracking your work schedule. For more on the development of this app and the ideas behind it, see my Time Tortoise category page.
Although far from done, Time Tortoise now has some basic functionality for tracking work time:
- Add, remove, and edit activities (the things you work on).
- Start and stop an activity timer, which creates a time segment (a start and end date/time).
- Manually add, remove, and edit time segments.
- Show the time segments associated with the selected activity.
- Filter the list of time segments by start date.
You could in theory use these features for tracking real work, but there’s a lot missing. However, before moving on to add more features, I spent some time this week on fit and finish.
Why Work on Fit and Finish Now?
Fit and finish refers to aspects of an app that don’t affect its basic functionality. Nevertheless, they can have a big impact on the user’s experience with the app. Fit and finish work usually happens near the end of a software release. But it can also be useful to do it in the middle. In addition to making an app more pleasant to use, fixing fit and finish bugs can uncover design problems, or suggest coding patterns that will make future development more efficient.
As professional software developers all discover, handling edge cases can be time-consuming. Although users spend most of their time exercising a program’s “happy path,” that doesn’t mean developers can ignore cases that rarely happen. An app that crashes when the user does something unexpected is frustrating to use.
Unexpected Activity Deletion and the Single Responsibility Principle
Time Tortoise allows the user to delete activities and time segments when they’re no longer needed. This worked fine in normal cases, but I found this week that my code didn’t behave very well when I deleted an activity while the timer was running. It shouldn’t be surprising that this is a challenging edge case, as I fixed a similar bug a couple of months ago, and I have even found a commercial time tracker with this problem.
Here are the repro steps:
- Add a new activity
- Click Start
- Click Delete
At this point, things are in a weird state: the start/stop button is disabled, but still says Stop. Also, the activity text box shows the name of the deleted activity, and the timer is still running (with no selected activity).
Given the state of the UI, you can then do this:
- Add another activity (which enables the Start/Stop button)
- Click Stop
And the program crashes with a
The root cause of these problems is that I was relying too much on a
MainViewModel property called
SelectedActivity. This property gets set when the user clicks on an activity in the list. It’s useful for keeping the activity name and time segment list up to date in the UI, and for starting a time segment for the correct activity when the user clicks the Start button. But once the timer starts, the user is free to select other activities and other time segments, at which point the selected activity may no longer have any relationship with the activity that is currently being timed.
So what I really need is a
StartedActivity property that always points to the activity associated with the active timer, or is set to
null if timing is not in progress. Once I introduced this new property and disentangled the concept of selected activity from the concept of started activity, things got a lot more stable.
This change is a small example of applying the single responsibility principle. The fact that a property of the correct type exists in a class doesn’t mean it’s appropriate to use it for multiple scenarios.
There was an analogous problem to the started activity bug over in the time segment list. Here’s how it manifested:
- Create or select an activity, and click Start.
- The time begins counting up in the time segment list.
- Select another activity.
- Select the original activity again.
- The new time segment is no longer updated with the current end time and duration.
As explained in Time Tortoise: Timers, the active time segment gets updated by the following process:
DispatcherTimertick event fires, calling a callback method in the main page code-behind.
- The callback method updates a time segment property in the main view model.
- XAML data binding updates the UI.
This process is a bit convoluted (execution moves from the UI code-behind to the view model, and then back to the UI), but as the Timers post explains, it seems to be the way things have to work with timers in a UWP MVVM app.
SelectedActivity, the process above relied on a
SelectedTimeSegment property. This isn’t ideal if the user selects other time segments while a timer is running. The solution, of course, is to introduce a
StartedTimeSegment to track that separately from the selected time segment.
We’re not quite done with the selected time segment. When the user selects an activity from the list, the app loads its time segments from the database (see Time Tortoise: Loading Related Entities with Entity Framework). The set of time segments gets transformed into an
ObservableCollection (actually a collection of
TimeSegmentViewModel) that binds to a list in the UI.
So if I click on activity A, its time segments end up in the collection. Then if I click on activity B, the collection is cleared and replaced with B’s time segments. What happens if I click back to activity A? As far as the user is concerned, it looks like the correct list of time segments. But each
TimeSegmentViewModel object that makes up the collection is different as far as C# is concerned from the ones in the original collection. They have the same activity ID, start times, and end times. But they’re stored at different memory addresses.
Why does this matter? Because if we’re holding a reference to the active time segment in
StartedTimeSegment, it will no longer point to any time segment in the
ObservableCollection after the new set is loaded. This explains why the time segment stops updating in the list. The timer’s tick event updates
StartedTimeSegment is no longer associated with any object in the list.
The solution: when loading new time segments, if
StartedTimeSegment is not null, compare its start time and activity ID with each time segment
ts. If they match, re-establish the link by setting
StartedTimeSegment = ts.
You might wonder whether this is sufficient. What if multiple time segments have the same start time? That’s actually against Time Tortoise rules, since we assume that the user can only do one thing at a time. However, that isn’t yet enforced in the code, so it’s a discussion for another week.