Flowkeeper is an independent Pomodoro Technique desktop timer for power users and professional teams. It is a simple tool, which focuses on doing one thing well. It is Free Software with open source. Visit flowkeeper.org for screenshots, downloads and FAQ.
Flowkeeper has a single major dependency -- Qt 6.6, which in turn requires Python 3.8 or later (3.9+ if you use Qt 6.7). To create installers and binary packages we build Flowkeeper on Ubuntu 22.04 using Python 3.11 and the latest version of Qt (6.7.2 at the time of writing). We also occasionally test it on Ubuntu 20.04 against Qt 6.2.4 and Python 3.8.10. Some features might not work as expected with Qt 6.2.x.
OLD ADVICE, which might not be relevant to you anymore: If you want to build it with Ubuntu 20.04 or Debian 11, both of which come with older versions of Python, you would have to compile Python 3.11. <-- Try the system Python version first.
The Websocket backend relies on Qt WebSockets module, which in turn requires OpenSSL 3.0. Note that some legacy OS like Ubuntu 20.04 require manual steps to install OpenSSL v3.
Create a virtual environment and install dependencies:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements-run.txt
Then you need to "generate resources", which means converting data files in /res
directory into
the corresponding Python classes. Whenever you make changes to files in /res
directory, you need
to rerun this command, too:
./generate-resource.sh
From here you can start coding. If you want to build an installer, refer to the CI/CD pipeline in
.github/workflows/build.yml
. For example, if you want to build a DEB file, you'd need to execute
pyinstaller installer/normal-build.spec
and then ./package-deb.sh
. The process is a bit more
involved for Windows and macOS. Note that you'd also need to install extra requirements from
requirements-build.txt
.
To run Flowkeeper:
PYTHONPATH=src python -m fk.desktop.desktop
To run unit tests w/test coverage (install requirements from requirements-test.txt
first):
PYTHONPATH=src python -m coverage run -m unittest discover -v fk.tests
python -m coverage html
TODO: Explain how to run e2e tests.
TODO: Explain how to build Windows and macOS installers. Also, document the signing process for Mac:
- Sign the APP via pyinstaller (see
codesign_identity
innormal-build.spec
), - Create a DMG: see
create-dmg
inbuild.yml
, - Sign the DMG:
codesign -s "Developer ID Application" Flowkeeper.dmg
, - Notarize the DMG:
- Create a keychain password for notarization (you only need to do it once):
xcrun notarytool store-credentials "notary-key" --apple-id "alice@example.com" --team-id "..." --password "..."
- Submit for notarization (this will take 10 minutes):
xcrun notarytool submit Flowkeeper.dmg --keychain-profile "notary-key" --wait
Flowkeeper's CI pipeline runs PyInstaller on Ubuntu and thus generates binaries which rely on glibc. Alpine is based on musl, so you'd get "symbol not found" errors in runtime if you try to run any of the "official" binaries.
You can still use Flowkeeper with Alpine. We tested it with the edge release + Xfce. Instructions:
- Install
py3-pyside6
package viaapk
. This is the only tricky bit. We couldn't install PySide6 via pip from inside the venv, as we'd normally do. - Clone this repo and create a Python Virtual Environment, which uses system packages:
python3 -m venv venv --system-site-packages
- The rest of the steps are the same as for any other Linux OS
Flowkeeper data model is strictly hierarchical:
- Tenant: AbstractDataContainer
- User: AbstractDataContainer
- Backlog: AbstractDataContainer
- Workitem: AbstractDataContainer
- Pomodoro: AbstractDataItem
- Workitem: AbstractDataContainer
- Backlog: AbstractDataContainer
- User: AbstractDataContainer
AbstractDataContainer
acts as a dict<uid, T>
, and AbstractDataItem
represents a domain object with
uid
, parent
, create_date
and last_modified_date
.
Due to its tree nature, sharing backlogs and workitems should be implemented via symlinks.
Whenever anything changes in the underlying data model, Flowkeeper emits events. To emit an event, the class needs
to subclass AbstractEventSource
. All UI updates should be based on those events.
-
AbstractEventSource
BeforeUserCreate(user_identity: str, user_name: str)
,AfterUserCreate(user: User)
BeforeUserDelete(user: User)
,AfterUserDelete(--//--)
BeforeUserRename(user: User, old_name: str, new_name: str)
,AfterUserRename(--//--)
BeforeBacklogCreate(backlog_name: str, backlog_owner: User, backlog_uid: str)
,AfterBacklogCreate(backlog: Backlog)
BeforeBacklogDelete(backlog: Backlog)
,AfterBacklogDelete(--//--)
BeforeBacklogRename(backlog: Backlog, old_name: str, new_name: str)
,AfterBacklogRename(--//--)
BeforeWorkitemCreate(backlog_uid: str, workitem_uid: str, workitem_name: str)
,AfterWorkitemCreate(workitem: Workitem)
BeforeWorkitemComplete(workitem: Workitem, target_state: str)
,AfterWorkitemComplete(--//--)
BeforeWorkitemStart(pomodoro: Pomodoro, workitem: Workitem, work_duration: int)
,AfterWorkitemStart(--//--)
BeforeWorkitemDelete(workitem: Workitem)
,AfterWorkitemDelete(--//--)
BeforeWorkitemRename(workitem: Workitem, old_name: str, new_name: str)
,AfterWorkitemRename(--//--)
BeforePomodoroAdd(workitem: Workitem, num_pomodoros: int)
,AfterPomodoroAdd(--//--)
BeforePomodoroRemove(workitem: Workitem, num_pomodoros: int, pomodoros: List<Pomodoro>)
,AfterPomodoroRemove(--//--)
BeforePomodoroWorkStart(pomodoro: Pomodoro, workitem: Workitem, work_duration: int)
,AfterPomodoroWorkStart(--//--)
BeforePomodoroRestStart(pomodoro: Pomodoro, workitem: Workitem, rest_duration: int)
,AfterPomodoroRestStart(--//--)
BeforePomodoroComplete(pomodoro: Pomodoro, workitem: Workitem, target_state: str)
,AfterPomodoroComplete
SourceMessagesRequested()
,SourceMessagesProcessed()
BeforeMessageProcessed(strategy: AbstractStrategy, auto: Bool)
,AfterMessageProcessed(--//--)
PongReceived(uid: str)
-
AbstractSettings
BeforeSettingsChanged(old_values: dict[str, str], new_values: dict[str, str])
,AfterSettingsChanged(--//--)
-
AbstractTableView
BeforeSelectionChanged(before: AbstractDataItem, after: AbstractDataItem)
,AfterSelectionChanged(--//--)
-
Application
AfterFontsChanged(main_font: QFont, header_font: QFont, application: Application)
AfterSourceChanged(source: AbstractEventSource)
-
Heartbeat
WentOnline(ping: int)
,WentOffline(after: int, last_received: datetime)
-
PomodoroTimer
TimerTick(timer: PomodoroTimer)
TimerWorkStart(timer: PomodoroTimer)
TimerWorkComplete(timer: PomodoroTimer)
TimerRestComplete(timer: PomodoroTimer, pomodoro: Pomodoro, workitem: Workitem)
The listeners can also pass the carry
parameter -- TODO: Explain it. The mandatory event
parameter for the callbacks
contains the event name.
You may find those as Strategies in the code. They correspond to the end-user actions / data mutations. Each command takes two or three parameters.
All data objects are keyed with UID
, which is an arbitrary string, typically a GUID.
Apart from "business data", each command has a few metadata fields, associated with it:
- Sequence number, used for ordering and checking uniqueness
- Execution timestamp
- Execution user
Note that users have emails as IDs.
CreateUser("<EMAIL>", "<USER_NAME>")
- Fails if a user with this email already exists, or a non-System user tries to execute this strategy. EmitsBeforeUserCreate
/AfterUserCreate
events.DeleteUser("<EMAIL>", "")
- Deletes a user recursively, i.e. executesDeleteBacklogStrategy
for each of the child backlogs. Fails if a user with a given email is not found, if a non-System user tries to execute this strategy, or if we are trying to delete a System user. EmitsBeforeUserDelete
/AfterUserDelete
events.RenameUser("<EMAIL>", "<NEW_NAME>")
- Fails if a user with a given email is not found, if a non-System user tries to execute this strategy, or if we are trying to rename a System user. EmitsBeforeUserRename
/AfterUserRename
events.
CreateBacklog("<UID>", "<BACKLOG_NAME>")
- Fails if a backlog with this UID already exists for the calling user. EmitsBeforeBacklogCreate
/AfterBacklogCreate
events.DeleteBacklog("<UID>", "")
- Deletes a backlog recursively, i.e. executesDeleteWorkitemStrategy
for each of the child workitems. Fails if a backlog with a given UID is not found for the calling user. EmitsBeforeBacklogDelete
/AfterBacklogDelete
events.RenameBacklog("<UID>", "<NEW_NAME>")
- Fails if a backlog with a given UID is not found for the calling user. EmitsBeforeBacklogRename
/AfterBacklogRename
events.
CreateWorkitem("<WORKITEM_UID>", "<BACKLOG_UID>", "<WORKITEM_NAME>")
- Fails if a backlog with this UID is not found or if a workitem with this UID already exists in that backlog. EmitsBeforeWorkitemCreate
/AfterWorkitemCreate
events.DeleteWorkitem("<UID>", "")
- Deletes a workitem recursively, i.e. executesVoidPomodoroStrategy
for each of the running pomodoros first. Fails if a workitem with a given UID is not found in any backlog. EmitsBeforeWorkitemDelete
/AfterWorkitemDelete
events.RenameWorkitem("<UID>", "<NEW_NAME>")
- Fails if a workitem with a given UID is not found in any backlog or if it is sealed (finished or canceled). Doesn't do anything if the new name is identical to the old one, otherwise emitsBeforeWorkitemRename
/AfterWorkitemRename
events.CompleteWorkitem("<UID>", "<STATE>")
- Seals the workitem with a given state (finished
orcanceled
) recursively, i.e. executesVoidPomodoroStrategy
for each of the running pomodoros, if any. Fails if a workitem with a given UID is not found in any backlog, if the target state is neitherfinished
norcanceled
, or if the workitem is already sealed. EmitsBeforeWorkitemComplete
/AfterWorkitemComplete
events.
Individual pomodoros don't have their own UIDs for simplicity. Although UIDs exist in runtime, they are generated on the fly and not persisted.
AddPomodoroStrategy("<WORKITEM_UID>", "<ADDED_COUNT>")
- Fails if the number of added pomodoros is less than 1, or if the workitem with specified UID is not found or sealed. EmitsBeforePomodoroAdd
/AfterPomodoroAdd
events.RemovePomodoroStrategy("<WORKITEM_UID>", "<REMOVED_COUNT>")
- Fails if the number of removed pomodoros is less than 1, or if the workitem with specified UID is not found or sealed, or if there's not enough startable (new
state) pomodoros in the workitem. EmitsBeforePomodoroRemove
/AfterPomodoroRemove
events.CompletePomodoroStrategy("<WORKITEM_UID>", "<TARGET_STATE>")
- DEPRECATED If the target state iscanceled
, it works as a synonym forVoidPomodoroStrategy
, otherwise it is ignored.VoidPomodoroStrategy("<WORKITEM_UID>")
- Fails if the workitem with specified UID is not found or sealed, or has no running pomodoros. EmitsBeforePomodoroComplete
/AfterPomodoroComplete
events with target statecanceled
.StartWorkStrategy("<WORKITEM_UID>", "<WORK_DURATION_IN_SECONDS>")
- Fails if the workitem with specified UID is not found or sealed, or has no startable (new
) pomodoros. If the specified work duration is0
, then the default value at the pomodoro creation moment is used. If a Workitem is not yet running, it switches intorunning
state, emitting a pair ofBeforeWorkitemStart
/AfterWorkitemStart
events. As long as it doesn't fail, this strategy emitsBeforePomodoroWorkStart
/AfterPomodoroWorkStart
events.StartRestStrategy("<WORKITEM_UID>", "<REST_DURATION_IN_SECONDS>")
- Fails if the workitem with specified UID is not found or is not running, or has no in-work (work
state) pomodoros. If the specified rest duration is0
, then the default value at the pomodoro creation moment is used. EmitsBeforePomodoroRestStart
/AfterPomodoroRestStart
events.
All below strategies are used with server-based event sources only, and are not persisted.
Authenticate("<EMAIL>", "<TOKEN>")
- This must be the first strategy sent by the client to the server, otherwise the latter closes the communication channel. Note that it doesn't specify token's format, leaving it to the authentication implementation.Replay("<AFTER_SEQUENCE>")
- Used for requesting the replay of the strategies from the server, starting from, but not including,#AFTER_SEQUENCE
. The server may respond with one or more messages with event history.ReplayCompleted()
- Not a true strategy (TODO -- Fix it), used by the server to signal the last strategy in the replayed list.Error("<ERROR_CODE>", "<ERROR_MESSAGE>")
- Sent by the server to report an error, e.g. wrong credentials passed toAuthenticate
strategy. Flowkeeper Desktop raises a UI exception when executing this strategy. This results in a message popup and a request to file a bug in GitHub.PingStrategy("<UID>", "")
- The client sends this to verify connection to the server. It expects to receive aPongStrategy
response with the matching UID immediately after. If the client doesn't receive a pong in a timely matter, it should switch to Offline / read-only mode.PongStrategy("<UID>", "")
- Sent by the server as a reply toPingStrategy
. If an Offline client receives a matching pong, it should switch back to Online mode.
TODO: Server-only strategies for Pomodoro Server.
# Application
actions.add('application.settings', "Settings", 'F10', None, Application.show_settings_dialog)
actions.add('application.quit', "Quit", 'Ctrl+Q', None, Application.quit_local)
actions.add('application.import', "Import...", 'Ctrl+I', None, Application.show_import_wizard)
actions.add('application.export', "Export...", 'Ctrl+E', None, Application.show_export_wizard)
actions.add('application.about', "About", '', None, Application.show_about)
# BacklogTableView
actions.add('backlogs_table.newBacklog', "New Backlog", 'Ctrl+N', None, BacklogTableView.create_backlog)
actions.add('backlogs_table.renameBacklog', "Rename Backlog", 'Ctrl+R', None, BacklogTableView.rename_selected_backlog)
actions.add('backlogs_table.deleteBacklog', "Delete Backlog", 'F8', None, BacklogTableView.delete_selected_backlog)
# WorkitemTableView
actions.add('workitems_table.newItem', "New Item", 'Ins', None, WorkitemTableView.create_workitem)
actions.add('workitems_table.renameItem', "Rename Item", 'F6', None, WorkitemTableView.rename_selected_workitem)
actions.add('workitems_table.deleteItem', "Delete Item", 'Del', None, WorkitemTableView.delete_selected_workitem)
actions.add('workitems_table.startItem', "Start Item", 'Ctrl+S', 'tool-next', WorkitemTableView.start_selected_workitem)
actions.add('workitems_table.completeItem', "Complete Item", 'Ctrl+P', 'tool-complete', WorkitemTableView.complete_selected_workitem)
actions.add('workitems_table.addPomodoro', "Add Pomodoro", 'Ctrl++', None, WorkitemTableView.add_pomodoro)
actions.add('workitems_table.removePomodoro', "Remove Pomodoro", 'Ctrl+-', None, WorkitemTableView.remove_pomodoro)
actions.add('workitems_table.showCompleted', "Show Completed Items", '', None, WorkitemTableView._toggle_show_completed_workitems, True, True)
# FocusWidget
actions.add('focus.voidPomodoro', "Void Pomodoro", 'Ctrl+V', "tool-void", FocusWidget._void_pomodoro)
actions.add('focus.nextPomodoro', "Next Pomodoro", None, "tool-next", FocusWidget._next_pomodoro)
actions.add('focus.completeItem', "Complete Item", None, "tool-complete", FocusWidget._complete_item)
actions.add('focus.showFilter', "Show Filter", None, "tool-filter", FocusWidget._display_filter)
# MainWindow
actions.add('window.showAll', "Show All", None, "tool-show-all", MainWindow.show_all)
actions.add('window.showFocus', "Show Focus", None, "tool-show-timer-only", MainWindow.show_focus)
actions.add('window.showMainWindow', "Show Main Window", None, "tool-show-timer-only", MainWindow.show_window)
actions.add('window.showBacklogs', "Backlogs", 'Ctrl+B', 'tool-backlogs', MainWindow.show_about)
actions.add('window.showUsers', "Team", 'Ctrl+T', 'tool-teams', MainWindow.toggle_users)
actions.add('window.showSearch', "Search...", 'Ctrl+F', '', MainWindow.show_search)
The core is designed with the following assumptions:
- All messages are sent by clients. The server doesn't add any messages of its own, even though it could have its own timers. This is done to simplify servers, and enable "dumb" backends like plain files.
- A client might be shut down at any moment. Most importantly, there can be zero clients running in the middle of a Pomodoro.
- Two clients must synchronize their changes in real time, meaning that they can't make conflicting changes offline. This is achieved via message sequencing.
- Some messages might be missing from the end of the list (e.g. the client is offline and they haven't arrived yet), but we can't have gaps in the middle of the history. It means that the history must always be consistent. If we detect an inconsistency in the hisory (e.g. 5 minutes of rest start on a pomodoro in "new" state, i.e. we missed the "work" state completely), such inconsistencies result in the parsing failure, crashing the client. We don't try to "fix" the history by adding records retroactively.
- The history is immutable, but we can create a new one, which is a compressed version of the original, as long as it results in the exact same final state of the data model.
- If a user tries to delete or complete a workitem in the middle of its own pomodoro, the core will void this pomodoro, emitting correct events.
- The history preserves all data, so we don't have to be too careful about deleting things. If a backlog, workitem or user is deleted -- the object simply gets deleted. We don't use "is_deleted" flags, and we don't move things to "orphaned" storage. If we need to restore a deleted object -- we'll find a way how to do it by processing the history.
- Strategies are only executed as a result of users' actions or timer events. Client startup or shutdown won't add any strategies to the history.
- The Timer never fires "in the past".
- All Pomodoros run and end implicitly. They can only be started and voided explicitly.
Copyright (c) 2023 - 2024 Constantine Kulak.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.