├── .github ├── ISSUE_TEMPLATE │ ├── Bug_Report.md │ ├── Feature_Request.md │ └── config.yml └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── harsh.go ├── harsh_test.go └── install.sh /.github/ISSUE_TEMPLATE/Bug_Report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** 12 | 13 | Steps to reproduce the behavior: 14 | 15 | 1. uno 16 | 2. dos 17 | 3. tres 18 | 19 | > Make sure your habit and log files are where they should be: 20 | 21 | ``` 22 | ~/.config/harsh/habits 23 | ~/.config/harsh/log 24 | ``` 25 | 26 | **Expected behavior** 27 | 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Environment (please complete the following information):** 31 | 32 | - OS: [e.g. mac, linux] 33 | - OS version: [uname -a] 34 | - Harsh version [harsh --version] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | Thanks! -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_Request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: enhancement 5 | --- 6 | 7 | We're trying to keep harsh as minimal as possible, but... 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/wakatara/harsh/discussions 5 | about: Ask questions and discuss with maintainers. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3.5.3 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Snapcraft 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install -y snapd 23 | sudo snap install snapcraft --classic 24 | - name: Set up Go 25 | uses: actions/setup-go@v4.1.0 26 | with: 27 | go-version: ">=1.24.3" 28 | - name: Snapcraft Login 29 | run: snapcraft whoami 30 | env: 31 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 32 | - name: Run GoReleaser 33 | uses: goreleaser/goreleaser-action@v4.4.0 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GORELEASER_HARSH_HOMEBREW_PUBLISHER }} 40 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Executable if built in app root 4 | harsh 5 | log 6 | habits 7 | cspell.json 8 | 9 | ### Go ### 10 | # Binaries for programs and plugins 11 | *.exe 12 | *.exe~ 13 | *.dll 14 | *.so 15 | *.dylib 16 | 17 | # Test binary, built with `go test -c` 18 | *.test 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | ### Go Patch ### 24 | /vendor/ 25 | /Godeps/ 26 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | - freebsd 11 | - openbsd 12 | - windows 13 | goarch: 14 | - 386 15 | - amd64 16 | - arm64 17 | - arm 18 | goarm: 19 | - 6 20 | # ignore: 21 | # - goos: openbsd 22 | # goarch: arm 23 | # - goos: openbsd 24 | # goarch: arm64 25 | archives: 26 | - name_template: >- 27 | {{ .ProjectName }}_ 28 | {{- title .Os }}_ 29 | {{- if eq .Arch "amd64" }}x86_64 30 | {{- else if eq .Arch "386" }}i386 31 | {{- else }}{{ .Arch }}{{ end }} 32 | checksum: 33 | name_template: "checksums.txt" 34 | snapshot: 35 | name_template: "{{ .Tag }}-next" 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - "^docs:" 41 | - "^test:" 42 | brews: 43 | - name: harsh 44 | repository: 45 | owner: wakatara 46 | name: homebrew-tap 47 | description: habit tracking for geeks. A minimalist CLI for examining your habits. 48 | homepage: https://github.com/wakatara/harsh 49 | license: MIT 50 | test: | 51 | system "#{bin}/harsh --version" 52 | snapcrafts: 53 | - name_template: "{{ .ProjectName }}_{{ .Arch }}" 54 | summary: habit tracking for geeks. A minimalist CLI for examining your habits. 55 | description: | 56 | Harsh provides a simple, portable, minimalist command line interface for 57 | tracking and examining your habits with text files and actionable 58 | consistency graphs, sparklines, and scoring to let you know how you are 59 | doing on progressing (or breaking) your habits. 60 | https://github.com/wakatara/harsh 61 | grade: stable 62 | confinement: strict 63 | license: MIT 64 | publish: true 65 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [dwm+conduct@wakatara.com](mailto:dwm+conduct@wakatara.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## Contributing to Harsh 4 | 5 | You're here to help? Awesome! Welcome! Thanks for offering. Please read the following sections to get a feel for the lay of the land, know how to ask questions, and how to work on something. 6 | 7 | All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). Please make sure you are welcome and friendly. Be kind. Be respectful. (BKBR). Be nice. It's a tough world out there. 8 | 9 | I'm pretty much happy for any contribution though since the idea is to keep the applicaiton minimalist new feature requests will be deliberated before addiiton. Code improvements, bug fixes, tests, documentation, translations, and any sort of unambiguous improvement is most welcome. Please [submit a PR](#pull-requests) 10 | 11 | ## Get in touch 12 | 13 | Report bugs, suggest features in the Issues on Github and view source on GitHub. 14 | I'm most easily pinged on twitter viaan @mention to [@awws](https://twitter.com/awws) 15 | For email, please look at the [About page on Daryl's blog](https://daryl.wakatara.com/about) 16 | 17 | ## Using the issue tracker 18 | 19 | The issue tracker is the preferred channel for [bug reports](#bugs), 20 | [features requests](#features) and [submitting pull 21 | requests](#pull-requests). 22 | 23 | ### Bug reports 24 | 25 | A bug is a _demonstrable problem_ that is caused by the code in the repository. 26 | Good bug reports are extremely helpful. 27 | 28 | Guidelines for bug reports: 29 | 30 | 1. **Use the GitHub issue search** — check if the issue has already been 31 | reported. 32 | 33 | 2. **Check if the issue has been fixed** — try to reproduce it using the 34 | latest `master` branch in the repository. 35 | 36 | 3. **Isolate the problem** — ideally create a reduced test case. 37 | 38 | A good bug report shouldn't leave others needing to chase you up for more 39 | information. Please try to be as detailed as possible in your report. What is 40 | your environment? What steps will reproduce the issue? What OS experiences the 41 | problem? What would you expect to be the outcome? All these details will help 42 | people to fix any potential bugs. 43 | 44 | There's a template that should help you out for reporting, but basically think along these lines. 45 | 46 | Example: 47 | 48 | > Short and descriptive example bug report title 49 | > 50 | > A summary of the issue and the browser/OS environment in which it occurs. If 51 | > suitable, include the steps required to reproduce the bug. 52 | > 53 | > 1. This is the first step 54 | > 2. This is the second step 55 | > 3. Further steps, etc. 56 | > 57 | > `` - a link to the reduced test case 58 | > 59 | > Any other information you want to share that is relevant to the issue being 60 | > reported. This might include the lines of code that you have identified as 61 | > causing the bug, and potential solutions (and your opinions on their 62 | > merits). 63 | 64 | ### Feature requests 65 | 66 | Feature requests are welcome. But take a moment to find out whether your idea 67 | fits with the scope and aims of Harsh. It's up to *you* to make a strong 68 | case to convince the project's developer(s) of the merits of this feature. Please 69 | provide as much detail and context as possible. 70 | 71 | ### Pull requests 72 | 73 | Good pull requests - patches, improvements, new features - are a fantastic 74 | help. They should remain focused in scope and avoid containing unrelated 75 | commits. 76 | 77 | **Please ask first** before embarking on any significant pull request (e.g. 78 | implementing features, refactoring code), otherwise you risk spending a lot of 79 | time working on something that the project's developers might not want to merge 80 | into the project. 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Daryl Manning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harsh Taskmaster 2 | 3 | harsh is habit tracking for geeks. A minimalist, command line tool for tracking 4 | and understanding your habits. 5 | 6 | Succinctly: it's quick, simple, and gets out of your way. And gives you excellent 7 | visibility on your habits. 8 | 9 | There are only 3 commands: `log`, `ask`, and `todo`. 10 | 11 | Designed for simplicity, visibility, and longevity, harsh uses simple text files 12 | for tracking that are human-grokable and editable in your favourite text editor. 13 | It's simpler, less messy, and more portable than commercial or mobile 14 | applications and less fussy to manage than emacs habit tracking (imho). While 15 | quantified individual tracking is exhaustive, important habits get lost in the 16 | data deluge, so this provides deliberate, explicit, habits to track and progress. 17 | 18 | It's written in Go and adds features and fixes on top of habitctl (its 19 | inspiration) such as skips, streak break warnings, stats, quantities, and as of 20 | `0.10.0` targets over intervals (like 3 times in a week). If you're a geek, 21 | I think you'll like it. Despite trying an exhaustive number of habit trackers, 22 | this was what worked for me. YMMV. If you're interested in why I wrote it, 23 | there's a [launch post about my motivations on my 24 | blog](https://daryl.wakatara.com/harsh-a-minimalist-cli-habit-tracker). 25 | 26 | My biggest hope that it helps you get done what you're trying to get done in 27 | your life and live a better one. 28 | 29 | ## How It Works 30 | 31 | Harsh lets you see your habits in a consistency graph (aka Seinfeld chain) from 32 | left to right over the time you've been doing the habit. The interface shows 33 | the last 100 days (but dynamically adapts to a smaller day length if your 34 | terminal window is too small.). 35 | 36 | For each day, you enter a yes/no/skip on whether you did the habit in question, 37 | and the horizontal line for ach habit is like an x-axis time graph showing you 38 | how your habits have fared. There's a sparkline graph at the top of the 39 | interface to show how you are doing percentage wise each day over the 100 days, 40 | and some interface hintint with a M(onday), W(ednesday), F(riday) label below 41 | that to jelp you figure out which day goes with what (very helpful if you find 42 | particular days are where your habits falldown consistently since you can then 43 | fix whatever that is in that day that messes things up.). 44 | 45 | Consistency graphs are also known as the "Seinfield Method" due to an 46 | apocryphal story about getting better at writing jokes, which I think is 47 | attributed to Brad Isaac in a (possibly never happened) piece of advice he got 48 | from the comedian Jerry Seinfeld. Basically, the idea was he had a big year 49 | long calendar with a day for every year. Every day he wrote jokes, he put an X 50 | down. He could see how consistent he was being. The idea is never to bfreak the 51 | chain. Harsh works the same way. The idea is turning something into a 52 | consistent habit makes you good -- eventually. The original place I saw it 53 | (Lifehacker) has killed the original post (largely I think because Seinfeld 54 | repudiated it) - but [you can see 55 | here](https://lifehacker.com/jerry-seinfelds-productivity-secret-281626) 56 | 57 | ## Commands 58 | 59 | There're 3 commands: `ask`, `log`, and `todo`. 60 | (and one subcommand `log stats`). 61 | 62 | `harsh ask` asks you about all your unanaswered habits. 63 | `harsh log` shows your full consistency graph across all habits. 64 | `harsh todo` shows your unanswered habits across all days. 65 | 66 | You can also use `harsh ask ` and `harsh log `to narrow down to answering just a single habit or to see the 68 | consistency graph for a single habit ( _ie. For the habit "Ship Every Day" 69 | typing `harsh ask ship` narrows your outcome responses to the unanswered days 70 | for that habit. Likewise, `harsh log ship` would show you only the consistency 71 | graph log for that habit._) 72 | 73 | `harsh ask` also allows you to use an ISO date as a substring (ie. 74 | `2025-02-20`) to answer just _that_ day's unanswered habit outcomes and the 75 | shortcut `harsh ask yday` or `harsh ask yd` to answer just yesterday's prompts. 76 | 77 | `harsh log stats` will provide summary tracking statistics of habits done, 78 | skipped, and chains broken. 79 | 80 | ## Installation 81 | 82 | harsh is available on OSX (and via homebrew), Linux (also as a Snap and 83 | homebrew), FreeBSD, OpenBSD, and Windows. A specific goal was increasing uptake 84 | and adoption of a portable, command line, text-based approach to habit tracking. 85 | harsh also supports ARM architectures for OSX (M1, M2, and M3 chipped Macs and 86 | Linux and BSDs) as of 0.8.8. Binaries for FreeBSD and OpenBSD are also available 87 | as of 0.8.23. 88 | 89 | ### Install via package manager 90 | 91 | **Homebrew tap** (Mac and Linux): 92 | 93 | ```sh 94 | brew install wakatara/tap/harsh 95 | ``` 96 | 97 | (this will also nicely alert you of updates when you `brew update`) 98 | 99 | **Snap** (Linux only): 100 | 101 | ```sh 102 | sudo snap install harsh 103 | ``` 104 | 105 | (you'll also get alerted when there are updates) 106 | 107 | ### Easy one-line install 108 | 109 | If you're not using a package manager, by far the easiest way to install harsh 110 | and get started is to use this one-line bash command if you're on OSX or Linux: 111 | 112 | ```bash 113 | curl -sSLf https://raw.githubusercontent.com/wakatara/harsh/master/install.sh | sh 114 | ``` 115 | 116 | The script downloads the latest release directly from github, verifies it 117 | cryptographically, and then moves the executable to `usr/local/bin` ready for 118 | immediate use on the command line. 119 | 120 | ### With working Go environment 121 | 122 | You may prefer a direct Go install if you have a working Go environment and Go 1.14+. 123 | 124 | ```bash 125 | go install github.com/wakatara/harsh 126 | 127 | ``` 128 | 129 | from the command line. Unlike a package manager like brew or snap, you won't 130 | get informed of new version releases. 131 | 132 | ### Manually 133 | 134 | Alternatively, you can download the pre-compiled binaries from 135 | [the releases page](https://github.com/wakatara/harsh/releases) 136 | and copy to the desired location (`/usr/local/bin` recommended), 137 | making sure it's in your `$PATH`. 138 | 139 | ### Compiling from source 140 | 141 | If you want to build from source cause you like that sort of thing, follow these 142 | steps: 143 | 144 | **Clone:** 145 | 146 | ```sh 147 | git clone https://github.com/wakatara/harsh 148 | cd harsh 149 | ``` 150 | 151 | **Get the dependencies:** 152 | 153 | ```sh 154 | go get ./... 155 | ``` 156 | 157 | **Build:** 158 | 159 | ```sh 160 | go build -o harsh . 161 | ``` 162 | 163 | **Verify it works:** 164 | 165 | ```sh 166 | ./harsh --version 167 | ``` 168 | 169 | You can then move the file to the desired location in your `$PATH`. 170 | 171 | ## Getting Started 172 | 173 | When you run `harsh ask` for the first time, it will set up the required files: 174 | 175 | ```sh 176 | $ harsh ask 177 | Welcome to harsh! 178 | 179 | Created /Users/daryl/.config/harsh/habits This file lists your habits. 180 | Created /Users/daryl/.config/harsh/log This file is your habit log. 181 | 182 | What? No habits yet? 183 | Open the habits file and edit the habit list using a text editor. 184 | Then run `harsh ask` to start tracking 185 | 'harsh todo` will show you undone habits for today. 186 | `harsh log` will show you a consistency graph of your efforts 187 | (trust me, it looks way cooler looking over time) 188 | Happy tracking! I genuinely hope this helps you get better. 189 | ``` 190 | 191 | On OSX and Linux based systems, the `habits` and `log` files will be under 192 | `~/.config/harsh/`. On Windows, you can find the files under `%APPDATA%\harsh`. 193 | 194 | Alternatively, you can set a different directory using the `HARSHPATH` environment variable. 195 | 196 | | :warning: **WARNING** | 197 | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 198 | | Ubuntu's snap overrides (sensible) defaults and forcibly places harsh's config _and_ log files in `~/snap/harsh/current/`. If you `snap remove` the harsh app, snap's uninstaller will nuke your config _and_ log files with the sandbox and you may lose your config and log data if it's not backed up (please _always_ exercise a good backup regime). For this reason, we _highly_ recommend snap users set the `HARSHPATH` env variable to `~/.config/harsh/` and move config and log files there right after installing to protect them. Or _never_ uninstall harsh. :grin: | 199 | 200 | Open the `habits` file in your text editor of choice (nano, vim, VS Code, Sublime, or emacs). 201 | 202 | You'll see an example file like this: 203 | 204 | ```sh 205 | harsh 206 | # This is your habits file. 207 | # It tells harsh what to track and how frequently in days. 208 | # 1 means daily, 7 means weekly, 14 every two weeks. 209 | # You can also track targets within a set number of days 210 | # For example, Gym 3 times a week would translate to 3/7 211 | # 0 is for tracking a habit. 0 frequency habits will not warn or score. 212 | # Examples: 213 | 214 | ! Dailies 215 | Gymmed: 1 216 | Bed by midnight: 1 217 | 218 | ! Weeklies 219 | Gymmed: 3/7 220 | Called Mom: 7 221 | 222 | ! Monthly+ 223 | Did Finances: 30 224 | New Skill: 90 225 | 226 | ! Tracking 227 | Too much coffee: 0 228 | Used harsh: 0 229 | ``` 230 | 231 | A big change starting in version `0.10.0`, harsh now allows you to pick a target 232 | number of times you want to perform a habit over a set interval of time (in 233 | days). Examples would be `3/7` for 3 times a week, `2/30` for twice a month etc 234 | etc. 235 | 236 | So, for example, wanting to make it to the gym 3 times a week (ie. 7 days) would 237 | translate into a line in the habit file like: 238 | 239 | ```sh 240 | Gymmed: 3/7 241 | ``` 242 | 243 | You can also simply pick a number of days you want to repeat a habit (also, for 244 | backwards compatibility with older habit files). 245 | 246 | _Note that this uses a rolling window of days than actual week delimiters (mostly 247 | because having hard start of week, month, and quarter breaks broke the point of 248 | consistency graphs when I implemented it. This is much nicer. Trust me.)._ 249 | 250 | If it's not obvious from the example file, habits can have any character that is 251 | not a `:` as that delimits the period. We also use `:` as the separator in log 252 | files as well for easy parsing. 253 | 254 | Headings are denoted by a "!" at the start of a line and allow you to categorize 255 | habits visually (useful if you have a lot of them). 256 | 257 | Comments can go in the habits file by starting a line with "#" and are not 258 | parsed by the program. 259 | 260 | The real trick of tracking is figuring out what habits you want to track 261 | building or breaking. Too many, you'll fail. Too few, and the app loses its 262 | edge. Too short-term, you feel good but fail on longer-term objectives. 263 | 264 | If you're getting started, try 5-8 and mix short term and long term and see how 265 | you go. Tracking your habits is _strangely_ also a habit you need to build. 266 | There're no right answers, but if this is new, [focus on foundational keystone 267 | habits](https://daryl.wakatara.com/resolution-keystone-habits-and-foundational-hacks/) 268 | that will feed future ones. If you're coming into this cold, I'd also recommend 269 | a good read of James Clear's Atomic Habits. 270 | 271 | Here are some ideas of what to track: 272 | 273 | - Went to bed on time 274 | - Got X hours of sleep 275 | - Inbox zero 276 | - Practiced Italian 277 | - Morning Pages 278 | - Blogged 279 | - Studied astrophysics 280 | - Socialized 281 | - Stuck to budget 282 | - Coded 283 | - 2 coffees only 284 | - Thanked someone 285 | - Went for a walk 286 | - Told SO they're amazing 287 | 288 | ## Usage and Commands 289 | 290 | Simply run `harsh ask` regularly, specify whether you did the habit from the 291 | prompt on a particular day (or needed to skip the habit for some reason - eg. 292 | could not clean apartment because you were away for week), and get pretty 293 | graphs! 294 | 295 | As of `v0.10.6` you can also add a fragment of a habit after `harsh ask` which 296 | will then only ask you about that the outcomes for that single habit. 297 | Taking the example of the `Pullups` habit below, if you had `Pullups: 5/7` 298 | in your habit file, typing `harsh ask pul` (or any variations of the habit 299 | name's string) will ask you only about the outcome for the unanswered todos 300 | for that habit (an oft-requested feature). 301 | 302 | `harsh ask` allows you to pick between `[y/n/s/⏎]` which is yes/no/skip/don't 303 | answer right now. CTRL-c breaks you out of the ask cycle at any point and 304 | returns you to your prompt. 305 | 306 | As of version `0.9.0`, to support longer term pattern tracking (and fallible 307 | memories), you can follow any of the `y | n | s` options with an optional typed 308 | `@` symbol to then denote a quantity you want to track for the daily habit 309 | (example: number of words written, km's run, pullups performed etc), and/or an 310 | optional typed `#` symbol for a comment. Primarily for analysis at a later date. 311 | 312 | As of `v0.10.12` we added date options to `harsh ask`. You can use an ISO Date (ie. `2025-02-14`) to answer _just_ that day's habits if you prefer narrowing your responses to specific days. The convenience functions `yday` and `yd` also work for yesterday. So, as examples, you can use `harsh ask 2025-02-14` and see the open habits for that day and `harsh ask yday` or `harsh ask yd` to answer just yesterday's habits. 313 | 314 | The `log stats` subcommand will also now total up any amounts you've entered for 315 | a habit and show you the total along with your streaks, skips, breaks, and days 316 | tracked. 317 | 318 | An example of how to use this for a habit like PullUps with `harsh ask pull` might be: 319 | 320 | This will, besides adding y to the log file, also record "15" and the comment 321 | "Crushed workout today" to your log file. The feature is backwards compatible 322 | even with older log files so even if you don't use it, you're alright. 0.9.0 323 | will also parse older logs. You must use a valid number in the `@` position and 324 | the only other caveat is if you use both an `@` and `#` in your prompt response, 325 | the `@` must come before the `#`. The only disallowed character in a prompt is 326 | `:` as it is used as a field separator in the log files (to make for easy 327 | importing and parsing by spreadsheet and other tools like pandas.) 328 | 329 | The annotating features are primarily for analysis at a later date to help 330 | uncover patterns between events, reasons you may have written a comment, and 331 | good or bad knock-on effects. 332 | 333 | Personally, I use comments sparingly and to denote why you had to skip, broke a 334 | consistency chain with an `n`, or for when you're trying to figure out 335 | something notable on a day so when you look back you can see why habit _X_ may 336 | or may not have succeeded. 337 | 338 | ```sh 339 | $ harsh ask pull 340 | 2024-01-05: 341 | Dailies 342 | PullUps ━ ━ ━ ━━ ━ ━ ━ ━ ━━━━━━━━━━━ ━ ━ ━[y/n/s/⏎] y @ 15 # Crushed workout today! 343 | ``` 344 | 345 | This would also be what `harsh ask 2024-01-05` would look like (ok, slightly cheating on examples, but...) 346 | 347 | ````sh 348 | $ harsh ask 2024-01-05 349 | 2024-01-05: 350 | Dailies 351 | PullUps ━ ━ ━ ━━ ━ ━ ━ ━ ━━━━━━━━━━━ ━ ━ ━[y/n/s/⏎] y @ 15 # Crushed workout today! 352 | 353 | 354 | 355 | Also, `harsh log` supports the same fragment querying `harsh ask` 356 | does. You can add a fragment of a habit after `harsh log` which will then only 357 | show you the consistency graph outcomes for that single habit. This supercedes 358 | the `harsh log check ` subcommand (which is not deprecated). 359 | 360 | Taking the example of the `Pullups` habit mentioned above, if you typed `harsh 361 | log pul` (or any variations of the habit name's string) you'll see just the 362 | consistency graph for that single habit along along with the sparkline for all 363 | habits and usual log command metrics. 364 | 365 | 366 | `harsh log` by itself shows your consistency graph for the last 100 days. 367 | 368 | 369 | ```sh 370 | $ harsh ask 371 | 2020-01-05: 372 | Dailies 373 | Meditated ━ ━ ━ ━━ ━ ━ ━ ━ ━━━━━━━━━━━ ━ ━ ━[y/n/s/⏎] y 374 | 375 | Weeklies 376 | Cleaned the apartment ━────── ━────── ━────── •······[y/n/s/⏎] n 377 | 378 | Tracking 379 | Had a headache ━ ━ ━━ ━━ ━ ━━ [y/n/s/⏎] n 380 | Used harsh ━ ━━━ ━ ━━━ ━ ━ ━ ━ ━ ━ ━ ━ ━━ ━ ━ ━━━━ ━ [y/n/s/⏎] y 381 | ... some habits omitted ... 382 | ```` 383 | 384 | (Some weeks later) 385 | 386 | ```sh 387 | $ harsh log 388 | ▄▃▃▄▄▃▄▆▆▆▅▆▆▇▆▄▃▄▆▃▆▃▆▂▅▄▃▄▅▆▅▃▃▃▆▂▄▅▄▅▅▅▆▄▄▆▇▆▅▅▄▃▅▆▄▆▃▃▂▅▆ 389 | Meditated ━ ━ ━ ━━ ━ ━ ━ ━ ━━━━━━━━━━━ ━ ━ ━━ 390 | Cleaned the apartment ━────── ━────── ━────── •······ 391 | Had a headache ━ ━ ━━ ━━ ━ ━━ 392 | Used harsh ━ ━━━ ━ ━━━ ━ ━ ━ ━ ━ ━ ━ ━ ━━ ━ ━ ━━━━ ━ ━ 393 | ... some habits omitted ... 394 | 395 | Yesterday's score: 88.3% 396 | ``` 397 | 398 | The sparkline at the top give a graphical representation of each day's score. 399 | The M W F stands for M(onday), W(ednesday), and F(riday) to provide visual 400 | hinting as to which days are which and possibly help diagnoze whether 401 | particular days are villains or heros in building your habits (How is this 402 | ueful? As an example, because of then way I had meetings structured on Tuesday, 403 | I often fell off the truck on certain habits on Tuesdays. Seeing this clearly 404 | in the graphs let me restructure my Tuesday work days to get me back on track.) 405 | 406 | The score at the bottom specifies how many of your habits you met that previous 407 | day of total possible and removes any you may have skipped from the calculation. 408 | 409 | ### Subcommands 410 | 411 | Run `harsh log stats` gives an analysis of your entire log file and a quantified 412 | idea of days you've been on streak, how many days you broke your consistency 413 | chain, and how many days you may have skipped -- as well as the total number of 414 | days you've been tracking a particular habit for (note: I swap out my file every 415 | year, but at least one person wanted this feature to track over 800+ days of log 416 | files they'd converted from another app.). It's also nicely coloured to help 417 | visually separate the information. 418 | 419 | In particular, when you've been tracking for longer than 100 days (the visual 420 | length of the consistency graph), you can get summary stats for your entire log 421 | (tbh, I did not think this was useful until I implemented it. I was wrong.). 422 | This can be surprisingly useful to see longer trends quantified. 423 | 424 | ```sh 425 | Slept 7h+ Streaks 173 days Breaks 147 days Skips 1 days Tracked 320 days 426 | Morning Pages Streaks 310 days Breaks 9 days Skips 2 days Tracked 320 days 427 | Share daily Streaks 320 days Breaks 0 days Skips 1 days Tracked 320 days 428 | Workouts Streaks 246 days Breaks 27 days Skips 48 days Tracked 320 days 429 | Read Daily Streaks 302 days Breaks 18 days Skips 0 days Tracked 320 days 430 | Write daily Streaks 320 days Breaks 0 days Skips 1 days Tracked 320 days 431 | TIL Streaks 285 days Breaks 26 days Skips 9 days Tracked 320 days 432 | Running Streaks 117 days Breaks 56 days Skips 148 days Tracked 320 days 433 | Blog Fortnightly Streaks 314 days Breaks 6 days Skips 0 days Tracked 320 days 434 | ... 435 | ``` 436 | 437 | As you can see here, I need to work on sleep more than anything, but digging 438 | down on these stats I shocked myself at skipped and breaks in workouts (covid 439 | vaccine related in some cases), and how I need to rethink how some of these were 440 | set up or I'm doing them (running, blogging etc.). The point is not how terrible 441 | I am, but that looking into the numbers revealed patterns (sleep, affects 442 | workouts, running, and TIL - today I learned - rather terribly). YMMV. 443 | 444 | Run `harsh log ` gives a slightly more in depth analysis of 445 | individual habits in conjunction with your topline aparkline. The idea here is 446 | that you can examine individual habits graphically against your topline to see 447 | if there are patterns of correlation between the variation in an individual 448 | habit and your overall daily score over time. 449 | 450 | Say you expect there's a close correlation between you not getting good sleep 451 | and your habits generally falling from a state of grace. It's easy to check 452 | with a `log ` and any string you type from your habits. In my 453 | case, I have a habit around getting to bed on time and getting a solid 7 I call 454 | `Bed by 12+Slept 7h+`. I can just use the handy `bed` as a short match term for 455 | the habit and get the result. 456 | 457 | ```sh 458 | $ harsh log bed 459 | 460 | █▇▇▇▇▇▇▇▇▇▇▇▇▇▇█▇▇▇▇▇▇█████▇██▇▇████████▇███████▇█████████████ 461 | Bed by 12+Slept 7h+ ━ ━ ━ ━━━━ ━━━ ━━ ━ ━━━ ━• ━━━━━━━━ ━━━━ ━━━━ 462 | 463 | ``` 464 | 465 | As we can see, there is a pretty close correlation here to not getting enough 466 | sleep (or going to bed too late) and me hitting all my daily habits. 467 | 468 | ### Done 469 | 470 | A done habit gives you a nice bright `━` on the consistency graph line. It's done. 471 | 472 | Additionally, the app checks in future days if you are still within the "every 473 | x days" period of performing the habit by drawing a dimmer `─` after the done 474 | marker to let you know you've satisfied the requirement for that habit. 475 | 476 | ### Skips 477 | 478 | Sometimes, it's impossible to exercise a habit cause life happens. If cleaning 479 | the house is a habit you want to exercise, but you happen to be away on 480 | a business trip, that is an impossibility. And sometimes, you decide to skip and 481 | push the habit to the next period (or a simple day or so). Skips being selected 482 | (s in the prompt) allows this to happen. A skip is denoted by a bright `•`. 483 | 484 | Much like satisfied habits where you've performed them once in the period, 485 | "skipified" habits let you know you're still withing the calculated grace period 486 | of the skip with a lighter dot `·`. 487 | 488 | ### Warnings 489 | 490 | harsh also has a warnings feature to help flag to you when you're in danger of 491 | breaking your consistency graph. Harsh will give you a warning by showing a "!" 492 | symbol in your upcoming habits. 493 | 494 | For habits of every less than 7 days period, you get a warning sigil on the day 495 | the chain will break if you do not perform the habit. For a week or longer, 496 | you'll start to see a warning sigil of `1 + days/7` rounded down (eg. so, 497 | 2 weeks' warning would get you the sigil 3 days ahead of breaking the chain 498 | etc.). 499 | 500 | ## Halps 501 | 502 | Enter `harsh help` if you're lost: 503 | 504 | ```text 505 | λ ~/harsh help 506 | NAME: 507 | Harsh - habit tracking for geeks 508 | 509 | USAGE: 510 | harsh [global options] command [command options] [arguments...] 511 | 512 | VERSION: 513 | 0.10.0 514 | 515 | DESCRIPTION: 516 | A simple, minimalist CLI for tracking and understanding habits. 517 | 518 | COMMANDS: 519 | ask, a Asks and records your undone habits 520 | log, l Shows graph of habits 521 | todo, t Shows undone habits for today. 522 | help, h Shows a list of commands or help for one command 523 | 524 | GLOBAL OPTIONS: 525 | --help, -h show help (default: false) 526 | --no-color, -n no colors in output (default: false) 527 | --version, -v print the version (default: false) 528 | ``` 529 | 530 | ## Quality of Life Usage Improvement 531 | 532 | As you increasingly use `harsh` for tracking, you'll inevitably end up making 533 | small errors in your log file or will want to edit your config file. While 534 | people have suggested adding a `harsh config` command to the app, this feels 535 | like overkill for the intended audience (geeks) but you can get the same effect 536 | through using aliases. 537 | 538 | As a simple quality of life improvement, add the following to your `bash`, 539 | `zsh`, `fish` (what I use), or shell of choice: 540 | 541 | ```fish 542 | alias h="harsh" 543 | alias hc="nvim ~/.config/harsh/habits" 544 | alias hl="nvim ~/.config/harsh/log" 545 | ``` 546 | 547 | For me, this equates to me ending up typing `h log` for the graph, `h ask` etc 548 | etc. When I need to edit the log file because I was a bit too itchy with my 549 | trigger finger (or decide I should add a note), `hl` is my friend. 550 | 551 | ## No Colour option 552 | 553 | New from 0.8.22: if you are logging the output of `harsh log stat` and other 554 | features which contains colour codes, you can instead use `--no-color` flag via 555 | `harsh --no-color [command]` (or `harsh -n [command]`) to suppress colourized 556 | output. This is also very helpful in n/vim with the `:.! harsh -n log stats` 557 | command stanza to record harsh's output to n/vim's buffer to augment your 558 | weekly, monthly, or daily tracking (learned that vim snippet from the feature 559 | requester!). 560 | 561 | Much like the above feature of accessing config and log files, you can alias 562 | `harsh -n` in your shell if you prefer to suppress all colour output coming from 563 | all harsh commands. 564 | 565 | ## License: MIT License 566 | 567 | _harsh_ is free software. You can redistribute it and/or modify it under the 568 | terms of the [MIT License](LICENSE). 569 | 570 | ## Contributing 571 | 572 | Primo, check out the [Contributing guidelines](CONTRIBUTING.md). 573 | 574 | 1. Fork it () 575 | 2. Create your feature branch (`git checkout -b my-new-feature`) 576 | 3. Commit your changes (`git commit -am 'Add some feature'`) 577 | 4. Push to the branch (`git push origin my-new-feature`) 578 | 5. Create a new Pull Request 579 | 580 | ## Contributors 581 | 582 | - [Daryl Manning](https://daryl.wakatara.com) - creator, maintainer, and evil mastermind 583 | - [JamesD](https://github.com/jnd-au) - fix for small terminal width panic. 584 | - [Aisling](https://github.com/ais) - improved configuration discovery. 585 | - [vchslv13](https://github.com/vchslv13) - improved shell installation script. 586 | - [manu-cyber](https://github.com/manu-cyber) - documentation fixes. 587 | 588 | ## Thanks 589 | 590 | - [Bjorn A](https://github.com/gaqzi) - for initial code review and improvements pre-release 591 | - [James RC](https://github.com/yarbelk) - for initial code review and improvements pre-release 592 | - [blinry](https://github.com/blinry) - for writing habitctl which harsh is an homage to and riff on. 593 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wakatara/harsh 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | cloud.google.com/go v0.121.0 7 | github.com/gookit/color v1.5.4 8 | github.com/urfave/cli/v2 v2.27.6 9 | golang.org/x/term v0.32.0 10 | ) 11 | 12 | require ( 13 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 14 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 15 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 16 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 17 | golang.org/x/sys v0.33.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= 2 | cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 8 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 9 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 10 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 14 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 15 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 16 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 17 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 18 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 19 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 20 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 21 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 22 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 23 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 24 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 25 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 26 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 27 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 28 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /harsh.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "math" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "cloud.google.com/go/civil" 17 | "github.com/gookit/color" 18 | "github.com/urfave/cli/v2" 19 | "golang.org/x/term" 20 | ) 21 | 22 | var configDir string 23 | 24 | type Days int 25 | 26 | type Habit struct { 27 | Heading string 28 | Name string 29 | Frequency string 30 | Target int 31 | Interval int 32 | FirstRecord civil.Date 33 | } 34 | 35 | // Outcome is the explicit recorded result of a habit 36 | // on a day (y, n, or s) and an optional amount and comment 37 | type Outcome struct { 38 | Result string 39 | Amount float64 40 | Comment string 41 | } 42 | 43 | // DailyHabit combines Day and Habit with an Outcome to yield Entries 44 | type DailyHabit struct { 45 | Day civil.Date 46 | Habit string 47 | } 48 | 49 | // HabitStats holds total stats for a Habit in the file 50 | type HabitStats struct { 51 | DaysTracked int 52 | Total float64 53 | Streaks int 54 | Breaks int 55 | Skips int 56 | } 57 | 58 | // Entries maps DailyHabit{ISO date + habit}: Outcome and log format 59 | type Entries map[DailyHabit]Outcome 60 | 61 | type Harsh struct { 62 | Habits []*Habit 63 | MaxHabitNameLength int 64 | CountBack int 65 | Entries *Entries 66 | } 67 | 68 | func main() { 69 | app := &cli.App{ 70 | Name: "Harsh", 71 | Usage: "habit tracking for geeks", 72 | Description: "A simple, minimalist CLI for tracking and understanding habits.", 73 | Version: "0.10.21", 74 | Flags: []cli.Flag{ 75 | &cli.BoolFlag{ 76 | Name: "no-color", 77 | Aliases: []string{"n"}, 78 | Usage: "no colors in output", 79 | }, 80 | }, 81 | Commands: []*cli.Command{ 82 | { 83 | Name: "ask", 84 | Aliases: []string{"a"}, 85 | Usage: "Asks and records your undone habits", 86 | Action: func(c *cli.Context) error { 87 | harsh := newHarsh() 88 | habit_fragment := c.Args().First() 89 | 90 | harsh.askHabits(habit_fragment) 91 | return nil 92 | }, 93 | }, 94 | { 95 | Name: "todo", 96 | Aliases: []string{"t"}, 97 | Usage: "Shows undone habits for today.", 98 | Action: func(_ *cli.Context) error { 99 | harsh := newHarsh() 100 | now := civil.DateOf(time.Now()) 101 | undone := harsh.getTodos(now, 8) 102 | 103 | heading := "" 104 | if len(undone) == 0 { 105 | fmt.Println("All todos logged up to today.") 106 | } else { 107 | for date, todos := range undone { 108 | t, _ := time.Parse(time.RFC3339, date+"T00:00:00Z") 109 | 110 | dayOfWeek := t.Weekday().String()[:3] 111 | 112 | color.Bold.Println(date + " " + dayOfWeek + ":") 113 | for _, habit := range harsh.Habits { 114 | for _, todo := range todos { 115 | if heading != habit.Heading && habit.Heading == todo { 116 | color.Bold.Printf("\n%s\n", habit.Heading) 117 | heading = habit.Heading 118 | } 119 | if habit.Name == todo { 120 | fmt.Printf("%*v", harsh.MaxHabitNameLength, todo+"\n") 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | return nil 128 | }, 129 | }, 130 | { 131 | Name: "log", 132 | Aliases: []string{"l"}, 133 | Usage: "Shows graph of logged habits", 134 | Action: func(c *cli.Context) error { 135 | harsh := newHarsh() 136 | habit_fragment := c.Args().First() 137 | 138 | // Checks for any fragment argument sent along only only asks for it, otherwise all 139 | habits := []*Habit{} 140 | if len(strings.TrimSpace(habit_fragment)) > 0 { 141 | for _, habit := range harsh.Habits { 142 | if strings.Contains(strings.ToLower(habit.Name), strings.ToLower(habit_fragment)) { 143 | habits = append(habits, habit) 144 | } 145 | } 146 | } else { 147 | habits = harsh.Habits 148 | } 149 | 150 | now := civil.DateOf(time.Now()) 151 | to := now 152 | from := to.AddDays(-harsh.CountBack) 153 | consistency := map[string][]string{} 154 | undone := harsh.getTodos(to, 7) 155 | 156 | sparkline, calline := harsh.buildSpark(from, to) 157 | fmt.Printf("%*v", harsh.MaxHabitNameLength, "") 158 | fmt.Print(strings.Join(sparkline, "")) 159 | fmt.Printf("\n") 160 | fmt.Printf("%*v", harsh.MaxHabitNameLength, "") 161 | fmt.Print(strings.Join(calline, "")) 162 | fmt.Printf("\n") 163 | 164 | heading := "" 165 | for _, habit := range habits { 166 | consistency[habit.Name] = append(consistency[habit.Name], harsh.buildGraph(habit, false)) 167 | if heading != habit.Heading { 168 | color.Bold.Printf("%s\n", habit.Heading) 169 | heading = habit.Heading 170 | } 171 | fmt.Printf("%*v", harsh.MaxHabitNameLength, habit.Name+" ") 172 | fmt.Print(strings.Join(consistency[habit.Name], "")) 173 | fmt.Printf("\n") 174 | } 175 | 176 | var undone_count int 177 | for _, v := range undone { 178 | undone_count += len(v) 179 | } 180 | 181 | yscore := fmt.Sprintf("%.1f", harsh.score(now.AddDays(-1))) 182 | tscore := fmt.Sprintf("%.1f", harsh.score(now)) 183 | fmt.Printf("\n" + "Yesterday's Score: ") 184 | fmt.Printf("%8v", yscore) 185 | fmt.Printf("%%\n") 186 | fmt.Printf("Today's Score: ") 187 | fmt.Printf("%12v", tscore) 188 | fmt.Printf("%%\n") 189 | if undone_count == 0 { 190 | fmt.Printf("All habits logged up to today.") 191 | } else { 192 | fmt.Printf("Total unlogged habits: ") 193 | fmt.Printf("%2v", undone_count) 194 | } 195 | fmt.Printf("\n") 196 | 197 | return nil 198 | }, 199 | Subcommands: []*cli.Command{ 200 | { 201 | Name: "stats", 202 | Aliases: []string{"s"}, 203 | Usage: "Shows habit stats for entire log file", 204 | Action: func(c *cli.Context) error { 205 | harsh := newHarsh() 206 | 207 | // to := civil.DateOf(time.Now()) 208 | // from := to.AddDays(-(365 * 5)) 209 | stats := map[string]HabitStats{} 210 | 211 | heading := "" 212 | for _, habit := range harsh.Habits { 213 | if c.Bool("no-color") { 214 | color.Disable() 215 | } 216 | if heading != habit.Heading { 217 | color.Bold.Printf("\n%s\n", habit.Heading) 218 | heading = habit.Heading 219 | } 220 | stats[habit.Name] = harsh.buildStats(habit) 221 | fmt.Printf("%*v", harsh.MaxHabitNameLength, habit.Name+" ") 222 | color.FgGreen.Printf("Streaks ") 223 | color.FgGreen.Printf("%4v", strconv.Itoa(stats[habit.Name].Streaks)) 224 | color.FgGreen.Printf(" days") 225 | fmt.Printf("%4v", "") 226 | color.FgRed.Printf("Breaks ") 227 | color.FgRed.Printf("%4v", strconv.Itoa(stats[habit.Name].Breaks)) 228 | color.FgRed.Printf(" days") 229 | fmt.Printf("%4v", "") 230 | color.FgYellow.Printf("Skips ") 231 | color.FgYellow.Printf("%4v", strconv.Itoa(stats[habit.Name].Skips)) 232 | color.FgYellow.Printf(" days") 233 | fmt.Printf("%4v", "") 234 | fmt.Printf("Tracked ") 235 | fmt.Printf("%4v", strconv.Itoa(stats[habit.Name].DaysTracked)) 236 | fmt.Printf(" days") 237 | if stats[habit.Name].Total == 0 { 238 | fmt.Printf("%4v", "") 239 | fmt.Printf(" ") 240 | fmt.Printf("%5v", "") 241 | fmt.Printf(" \n") 242 | } else { 243 | fmt.Printf("%4v", "") 244 | color.FgBlue.Printf("Total ") 245 | color.FgBlue.Printf("%5v", (stats[habit.Name].Total)) 246 | color.FgBlue.Printf(" \n") 247 | } 248 | } 249 | return nil 250 | }, 251 | }, 252 | }, 253 | }, 254 | }, 255 | } 256 | 257 | sort.Sort(cli.FlagsByName(app.Flags)) 258 | sort.Sort(cli.CommandsByName(app.Commands)) 259 | 260 | err := app.Run(os.Args) 261 | if err != nil { 262 | log.Fatal(err) 263 | } 264 | } 265 | 266 | func newHarsh() *Harsh { 267 | config := findConfigFiles() 268 | habits, maxHabitNameLength := loadHabitsConfig(config) 269 | entries := loadLog(config) 270 | now := civil.DateOf(time.Now()) 271 | to := now 272 | from := to.AddDays(-365 * 5) 273 | entries.firstRecords(from, to, habits) 274 | width, _, err := term.GetSize(int(os.Stdout.Fd())) 275 | if err != nil { 276 | log.Fatal(err) 277 | } 278 | countBack := max(1, min(width-maxHabitNameLength-2, 100)) 279 | return &Harsh{habits, maxHabitNameLength, countBack, entries} 280 | } 281 | 282 | // Ask function prompts 283 | func (h *Harsh) askHabits(check string) { 284 | now := civil.DateOf(time.Now()) 285 | to := now 286 | from := to.AddDays(-h.CountBack - 40) 287 | 288 | // Goes back 8 days to check unresolved entries 289 | checkBackDays := 10 290 | // If log file is empty, we onboard the user 291 | // For onboarding, we ask how many days to start tracking from 292 | if len(*h.Entries) == 0 { 293 | checkBackDays = onboard() 294 | for _, habit := range h.Habits { 295 | habit.FirstRecord = to.AddDays(-checkBackDays) 296 | } 297 | } 298 | // Checks for any fragment argument sent along only only asks for it, otherwise all 299 | habits := []*Habit{} 300 | if len(strings.TrimSpace(check)) > 0 { 301 | ask_date, err := civil.ParseDate(check) 302 | if err == nil { 303 | from = ask_date 304 | to = from.AddDays(0) 305 | habits = h.Habits 306 | } 307 | if check == "yday" || check == "yd" { 308 | from = to.AddDays(-1) 309 | to = from.AddDays(0) 310 | habits = h.Habits 311 | } else { 312 | for _, habit := range h.Habits { 313 | if strings.Contains(strings.ToLower(habit.Name), strings.ToLower(check)) { 314 | habits = append(habits, habit) 315 | } 316 | } 317 | } 318 | } else { 319 | habits = h.Habits 320 | } 321 | 322 | dayHabits := h.getTodos(to, checkBackDays) 323 | 324 | if len(habits) == 0 { 325 | fmt.Println("You have no habits that contain that string") 326 | } else { 327 | for dt := from; !dt.After(to); dt = dt.AddDays(1) { 328 | if dayhabit, ok := dayHabits[dt.String()]; ok { 329 | 330 | day, _ := time.Parse(time.RFC3339, dt.String()+"T00:00:00Z") 331 | dayOfWeek := day.Weekday().String()[:3] 332 | 333 | color.Bold.Println(dt.String() + " " + dayOfWeek + ":") 334 | 335 | // Go through habit file ordered habits, 336 | // Check if in returned todos for day and prompt 337 | heading := "" 338 | for _, habit := range habits { 339 | for _, dh := range dayhabit { 340 | if habit.Name == dh && (dt.After(habit.FirstRecord) || dt == habit.FirstRecord) { 341 | if heading != habit.Heading { 342 | color.Bold.Printf("\n%s\n", habit.Heading) 343 | heading = habit.Heading 344 | } 345 | for { 346 | fmt.Printf("%*v", h.MaxHabitNameLength, habit.Name+" ") 347 | fmt.Print(h.buildGraph(habit, true)) 348 | fmt.Printf(" [y/n/s/⏎] ") 349 | 350 | reader := bufio.NewReader(os.Stdin) 351 | habitResultInput, err := reader.ReadString('\n') 352 | if err != nil { 353 | fmt.Fprintln(os.Stderr, err) 354 | } 355 | 356 | // No input 357 | if len(habitResultInput) == 1 { 358 | break 359 | } 360 | 361 | // Sanitize : colons out of string for log files 362 | habitResultInput = strings.ReplaceAll(habitResultInput, ":", "") 363 | 364 | var result, amount, comment string 365 | atIndex := strings.Index(habitResultInput, "@") 366 | hashIndex := strings.Index(habitResultInput, "#") 367 | 368 | if atIndex > 0 && hashIndex > 0 && atIndex < hashIndex { 369 | parts := strings.SplitN(habitResultInput, "@", 2) 370 | secondParts := strings.SplitN(parts[1], "#", 2) 371 | result = strings.TrimSpace(parts[0]) 372 | amount = strings.TrimSpace(secondParts[0]) 373 | comment = strings.TrimSpace(secondParts[1]) 374 | } 375 | // only has an @ Amount 376 | if hashIndex == -1 && atIndex > 0 { 377 | parts := strings.SplitN(habitResultInput, "@", 2) 378 | result = strings.TrimSpace(parts[0]) 379 | amount = strings.TrimSpace(parts[1]) 380 | comment = "" 381 | } 382 | // only has a # comment 383 | if atIndex == -1 && hashIndex > 0 { 384 | parts := strings.SplitN(habitResultInput, "#", 2) 385 | result = strings.TrimSpace(parts[0]) 386 | amount = "" 387 | comment = strings.TrimSpace(parts[1]) 388 | } 389 | if atIndex == -1 && hashIndex == -1 { 390 | result = strings.TrimSpace(habitResultInput) 391 | } 392 | 393 | if strings.ContainsAny(result, "yns") && len(result) == 1 { 394 | writeHabitLog(dt, habit.Name, result, comment, amount) 395 | // Updates the Entries map to get updated buildGraph across days 396 | famount, _ := strconv.ParseFloat(amount, 64) 397 | (*h.Entries)[DailyHabit{dt, habit.Name}] = Outcome{Result: result, Amount: famount, Comment: comment} 398 | break 399 | } 400 | 401 | color.FgRed.Printf("%*v", h.MaxHabitNameLength+22, "Sorry! Please choose from") 402 | color.FgRed.Printf(" [y/n/s/⏎] " + "(+ optional @ amounts then # comments)" + "\n") 403 | } 404 | } 405 | } 406 | } 407 | } 408 | } 409 | } 410 | } 411 | 412 | func (e *Entries) firstRecords(from civil.Date, to civil.Date, habits []*Habit) { 413 | for dt := to; !dt.Before(from); dt = dt.AddDays(-1) { 414 | for _, habit := range habits { 415 | if _, ok := (*e)[DailyHabit{Day: dt, Habit: habit.Name}]; ok { 416 | habit.FirstRecord = dt 417 | } 418 | } 419 | } 420 | } 421 | 422 | func (h *Harsh) getTodos(to civil.Date, daysBack int) map[string][]string { 423 | tasksUndone := map[string][]string{} 424 | dayHabits := map[string]bool{} 425 | from := to.AddDays(-daysBack) 426 | noFirstRecord := civil.Date{Year: 0, Month: 0, Day: 0} 427 | // Put in conditional for onboarding starting at 0 days or normal lookback 428 | if daysBack == 0 { 429 | for _, habit := range h.Habits { 430 | dayHabits[habit.Name] = true 431 | } 432 | for habit := range dayHabits { 433 | tasksUndone[from.String()] = append(tasksUndone[from.String()], habit) 434 | } 435 | } else { 436 | for dt := to; !dt.Before(from); dt = dt.AddDays(-1) { 437 | // build map of habit array to make deletions cleaner 438 | // +more efficient than linear search array deletes 439 | for _, habit := range h.Habits { 440 | dayHabits[habit.Name] = true 441 | } 442 | 443 | for _, habit := range h.Habits { 444 | 445 | if _, ok := (*h.Entries)[DailyHabit{Day: dt, Habit: habit.Name}]; ok { 446 | delete(dayHabits, habit.Name) 447 | } 448 | // Edge case for 0 day lookback onboard onboards and does not complete at onboard time 449 | if habit.FirstRecord == noFirstRecord && dt != to { 450 | delete(dayHabits, habit.Name) 451 | } 452 | // Remove days before the first observed outcome of habit 453 | if dt.Before(habit.FirstRecord) { 454 | delete(dayHabits, habit.Name) 455 | } 456 | } 457 | 458 | for habit := range dayHabits { 459 | tasksUndone[dt.String()] = append(tasksUndone[dt.String()], habit) 460 | } 461 | } 462 | } 463 | return tasksUndone 464 | } 465 | 466 | // Consistency graph, sparkline, and scoring functions 467 | func (h *Harsh) buildSpark(from civil.Date, to civil.Date) ([]string, []string) { 468 | sparkline := []string{} 469 | calline := []string{} 470 | sparks := []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} 471 | i := 0 472 | LetterDay := map[string]string{ 473 | "Sunday": " ", "Monday": "M", "Tuesday": " ", "Wednesday": "W", 474 | "Thursday": " ", "Friday": "F", "Saturday": " ", 475 | } 476 | 477 | for d := from; !d.After(to); d = d.AddDays(1) { 478 | dailyScore := h.score(d) 479 | // divide score into score to map to sparks slice graphic for sparkline 480 | if dailyScore == 100 { 481 | i = 8 482 | } else { 483 | i = int(math.Ceil(dailyScore / float64(100/(len(sparks)-1)))) 484 | } 485 | t, _ := time.Parse(time.RFC3339, d.String()+"T00:00:00Z") 486 | w := t.Weekday().String() 487 | 488 | calline = append(calline, LetterDay[w]) 489 | sparkline = append(sparkline, sparks[i]) 490 | } 491 | 492 | return sparkline, calline 493 | } 494 | 495 | func (h *Harsh) buildGraph(habit *Habit, ask bool) string { 496 | graphLen := h.CountBack 497 | if ask { 498 | graphLen = max(1, graphLen-12) 499 | } 500 | var graphDay string 501 | var consistency strings.Builder 502 | 503 | to := civil.DateOf(time.Now()) 504 | from := to.AddDays(-graphLen) 505 | consistency.Grow(graphLen) 506 | 507 | for d := from; !d.After(to); d = d.AddDays(1) { 508 | if outcome, ok := (*h.Entries)[DailyHabit{Day: d, Habit: habit.Name}]; ok { 509 | switch { 510 | case outcome.Result == "y": 511 | graphDay = "━" 512 | case outcome.Result == "s": 513 | graphDay = "•" 514 | // look at cases of "n" being entered but 515 | // within bounds of the habit every x days 516 | case satisfied(d, habit, *h.Entries): 517 | graphDay = "─" 518 | case skipified(d, habit, *h.Entries): 519 | graphDay = "·" 520 | case outcome.Result == "n": 521 | graphDay = " " 522 | } 523 | } else { 524 | if warning(d, habit, *h.Entries) && (to.DaysSince(d) < 14) { 525 | // warning: sigils max out at 2 weeks (~90 day habit in formula) 526 | graphDay = "!" 527 | } else if d.After(habit.FirstRecord) { 528 | // For people who miss days but then put in later ones 529 | graphDay = "◌" 530 | } else { 531 | graphDay = " " 532 | } 533 | } 534 | consistency.WriteString(graphDay) 535 | } 536 | 537 | return consistency.String() 538 | } 539 | 540 | func (h *Harsh) buildStats(habit *Habit) HabitStats { 541 | var streaks, breaks, skips int 542 | var total float64 543 | now := civil.DateOf(time.Now()) 544 | to := now 545 | 546 | for d := habit.FirstRecord; !d.After(to); d = d.AddDays(1) { 547 | if outcome, ok := (*h.Entries)[DailyHabit{Day: d, Habit: habit.Name}]; ok { 548 | switch { 549 | case outcome.Result == "y": 550 | streaks += 1 551 | case outcome.Result == "s": 552 | skips += 1 553 | // look at cases of "n" being entered but streak within 554 | // bounds of a sliding window of the habit every x days 555 | case satisfied(d, habit, *h.Entries): 556 | streaks += 1 557 | case skipified(d, habit, *h.Entries): 558 | skips += 1 559 | case outcome.Result == "n": 560 | breaks += 1 561 | } 562 | total += outcome.Amount 563 | } 564 | } 565 | return HabitStats{DaysTracked: int((to.DaysSince(habit.FirstRecord)) + 1), Streaks: streaks, Breaks: breaks, Skips: skips, Total: total} 566 | } 567 | 568 | func satisfied(d civil.Date, habit *Habit, entries Entries) bool { 569 | if habit.Target <= 1 && habit.Interval == 1 { 570 | return false 571 | } 572 | 573 | // Define the sliding window bounds (looking back and forward) 574 | start := d.AddDays(-habit.Interval) 575 | if start.Before(habit.FirstRecord) { 576 | start = habit.FirstRecord 577 | } 578 | end := d 579 | 580 | // previousStreakBreak := false 581 | 582 | // Slide the window one day at a time 583 | for winStart := start; !winStart.After(end.AddDays(habit.Interval - 2)); winStart = winStart.AddDays(1) { 584 | winEnd := winStart.AddDays(habit.Interval - 2) 585 | 586 | count := 0 587 | for dt := winStart; !dt.After(winEnd); dt = dt.AddDays(1) { 588 | if v, ok := entries[DailyHabit{Day: dt, Habit: habit.Name}]; ok && v.Result == "y" { 589 | count++ 590 | } 591 | } 592 | 593 | if habit.Target == 1 && count == 2 { 594 | return true 595 | } 596 | if count >= habit.Target { 597 | return true 598 | } 599 | } 600 | 601 | return false 602 | } 603 | 604 | func skipified(d civil.Date, habit *Habit, entries Entries) bool { 605 | if habit.Target <= 1 && habit.Interval == 1 { 606 | return false 607 | } 608 | 609 | from := d 610 | to := d.AddDays(-int(math.Ceil(float64(habit.Interval) / float64(habit.Target)))) 611 | for dt := from; !dt.Before(to); dt = dt.AddDays(-1) { 612 | if v, ok := entries[DailyHabit{Day: dt, Habit: habit.Name}]; ok { 613 | if v.Result == "s" { 614 | return true 615 | } 616 | } 617 | } 618 | return false 619 | } 620 | 621 | func warning(d civil.Date, habit *Habit, entries Entries) bool { 622 | if habit.Target < 1 { 623 | return false 624 | } 625 | 626 | warningDays := int(habit.Interval)/7 + 1 627 | to := d 628 | from := d.AddDays(-int(habit.Interval) + warningDays) 629 | noFirstRecord := civil.Date{Year: 0, Month: 0, Day: 0} 630 | for dt := from; !dt.After(to); dt = dt.AddDays(1) { 631 | if v, ok := entries[DailyHabit{Day: dt, Habit: habit.Name}]; ok { 632 | switch v.Result { 633 | case "y": 634 | return false 635 | case "s": 636 | return false 637 | } 638 | } 639 | // Edge case for 0 day onboard and later completes null entry habits 640 | if habit.FirstRecord == noFirstRecord { 641 | return false 642 | } 643 | if dt.Before(habit.FirstRecord) { 644 | return false 645 | } 646 | } 647 | return true 648 | } 649 | 650 | func (h *Harsh) score(d civil.Date) float64 { 651 | scored := 0.0 652 | skipped := 0.0 653 | scorableHabits := 0.0 654 | 655 | for _, habit := range h.Habits { 656 | if habit.Target > 0 && !d.Before(habit.FirstRecord) { 657 | scorableHabits++ 658 | if outcome, ok := (*h.Entries)[DailyHabit{Day: d, Habit: habit.Name}]; ok { 659 | switch { 660 | case outcome.Result == "y": 661 | scored++ 662 | case outcome.Result == "s": 663 | skipped++ 664 | // look at cases of n being entered but 665 | // within bounds of the habit every x days 666 | case satisfied(d, habit, *h.Entries): 667 | scored++ 668 | case skipified(d, habit, *h.Entries): 669 | skipped++ 670 | } 671 | } 672 | } 673 | } 674 | 675 | var score float64 676 | // Edge case on if there is nothing to score and the scorable vs skipped issue 677 | if scorableHabits == 0 { 678 | score = 0.0 679 | } else { 680 | score = 100.0 // deal with scorable habits - skipped == 0 causing divide by zero issue 681 | } 682 | if scorableHabits-skipped != 0 { 683 | score = (scored / (scorableHabits - skipped)) * 100 684 | } 685 | return score 686 | } 687 | 688 | ////////////////////////////////////// 689 | // Loading and writing file functions 690 | ////////////////////////////////////// 691 | 692 | // loadHabitsConfig loads habits in config file ordered slice 693 | func loadHabitsConfig(configDir string) ([]*Habit, int) { 694 | file, err := os.Open(filepath.Join(configDir, "/habits")) 695 | if err != nil { 696 | log.Fatal(err) 697 | } 698 | defer file.Close() 699 | 700 | scanner := bufio.NewScanner(file) 701 | 702 | var heading string 703 | var habits []*Habit 704 | for scanner.Scan() { 705 | if len(scanner.Text()) > 0 { 706 | if scanner.Text()[0] == '!' { 707 | result := strings.Split(scanner.Text(), "! ") 708 | heading = result[1] 709 | } else if scanner.Text()[0] != '#' { 710 | result := strings.Split(scanner.Text(), ": ") 711 | h := Habit{Heading: heading, Name: result[0], Frequency: result[1]} 712 | (&h).parseHabitFrequency() 713 | habits = append(habits, &h) 714 | } 715 | } 716 | } 717 | 718 | maxHabitNameLength := 0 719 | for _, habit := range habits { 720 | if len(habit.Name) > maxHabitNameLength { 721 | maxHabitNameLength = len(habit.Name) 722 | } 723 | } 724 | 725 | return habits, maxHabitNameLength + 10 726 | } 727 | 728 | // loadLog reads entries from log file 729 | func loadLog(configDir string) *Entries { 730 | file, err := os.Open(filepath.Join(configDir, "/log")) 731 | if err != nil { 732 | log.Fatal(err) 733 | } 734 | defer file.Close() 735 | 736 | scanner := bufio.NewScanner(file) 737 | 738 | entries := Entries{} 739 | line_count := 0 740 | for scanner.Scan() { 741 | line_count++ 742 | if len(scanner.Text()) > 0 { 743 | if scanner.Text()[0] != '#' { 744 | // Discards comments from read record read as result[3] 745 | result := strings.Split(scanner.Text(), " : ") 746 | cd, err := civil.ParseDate(result[0]) 747 | if err != nil { 748 | fmt.Println("Error parsing log date format.") 749 | } 750 | switch len(result) { 751 | case 5: 752 | if result[4] == "" { 753 | result[4] = "0" 754 | } 755 | amount, err := strconv.ParseFloat(result[4], 64) 756 | if err != nil { 757 | fmt.Printf("Error: there is a non-number in your log file at line %d where we expect a number.\n", line_count) 758 | } 759 | entries[DailyHabit{Day: cd, Habit: result[1]}] = Outcome{Result: result[2], Comment: result[3], Amount: amount} 760 | case 4: 761 | entries[DailyHabit{Day: cd, Habit: result[1]}] = Outcome{Result: result[2], Comment: result[3], Amount: 0.0} 762 | default: 763 | entries[DailyHabit{Day: cd, Habit: result[1]}] = Outcome{Result: result[2], Comment: "", Amount: 0.0} 764 | } 765 | } 766 | } 767 | } 768 | 769 | if err := scanner.Err(); err != nil { 770 | log.Fatal(err) 771 | } 772 | 773 | return &entries 774 | } 775 | 776 | func (habit *Habit) parseHabitFrequency() { 777 | freq := strings.Split(habit.Frequency, "/") 778 | target, err := strconv.Atoi(strings.TrimSpace(freq[0])) 779 | if err != nil { 780 | fmt.Println("Error: A frequency in your habit file has a non-integer before the slash.") 781 | fmt.Println("The problem entry to fix is: " + habit.Name + " : " + habit.Frequency) 782 | os.Exit(1) 783 | } 784 | 785 | var interval int 786 | if len(freq) == 1 { 787 | if target == 0 { 788 | interval = 1 789 | } else { 790 | interval = target 791 | target = 1 792 | } 793 | } else { 794 | interval, err = strconv.Atoi(strings.TrimSpace(freq[1])) 795 | if err != nil || interval == 0 { 796 | fmt.Println("Error: A frequency in your habit file has a non-integer or zero after the slash.") 797 | fmt.Println("The problem entry to fix is: " + habit.Name + " : " + habit.Frequency) 798 | os.Exit(1) 799 | } 800 | } 801 | if target > interval { 802 | fmt.Println("Error: A frequency in your habit file has a target value greater than the interval period.") 803 | fmt.Println("The problem entry to fix is: " + habit.Name + " : " + habit.Frequency) 804 | os.Exit(1) 805 | } 806 | habit.Target = target 807 | habit.Interval = interval 808 | } 809 | 810 | // writeHabitLog writes the log entry for a habit to file 811 | func writeHabitLog(d civil.Date, habit string, result string, comment string, amount string) error { 812 | fileName := filepath.Join(configDir, "/log") 813 | f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 814 | if err != nil { 815 | return fmt.Errorf("opening log file: %w", err) 816 | } 817 | defer f.Close() 818 | 819 | if _, err := f.Write([]byte(d.String() + " : " + habit + " : " + result + " : " + comment + " : " + amount + "\n")); err != nil { 820 | f.Close() // ignore error; Write error takes precedence 821 | return fmt.Errorf("writing log file: %w", err) 822 | } 823 | if err := f.Close(); err != nil { 824 | log.Fatal(err) 825 | } 826 | return nil 827 | } 828 | 829 | // findConfigFile checks os relevant habits and log file exist, returns path 830 | // If they do not exist, calls writeNewHabits and writeNewLog 831 | func findConfigFiles() string { 832 | configDir = os.Getenv("HARSHPATH") 833 | 834 | if len(configDir) == 0 { 835 | if runtime.GOOS == "windows" { 836 | configDir = filepath.Join(os.Getenv("APPDATA"), "harsh") 837 | } else { 838 | configDir = filepath.Join(os.Getenv("HOME"), ".config/harsh") 839 | } 840 | } 841 | 842 | if _, err := os.Stat(filepath.Join(configDir, "habits")); err == nil { 843 | } else { 844 | welcome(configDir) 845 | } 846 | 847 | return configDir 848 | } 849 | 850 | // welcome a new user and creates example habits and log files 851 | func welcome(configDir string) { 852 | createExampleHabitsFile(configDir) 853 | createNewLogFile(configDir) 854 | fmt.Println("Welcome to harsh!") 855 | fmt.Println("Created " + filepath.Join(configDir, "/habits") + " This file lists your habits.") 856 | fmt.Println("Created " + filepath.Join(configDir, "/log") + " This file is your habit log.") 857 | fmt.Println("") 858 | fmt.Println("No habits of your own yet?") 859 | fmt.Println("Open your habits file @ " + filepath.Join(configDir, "/habits")) 860 | fmt.Println("with a text editor (nano, vim, VS Code, Atom, emacs) and modify and save the habits list.") 861 | fmt.Println("Then:") 862 | fmt.Println("Run harsh ask to start tracking") 863 | fmt.Println("Running harsh todo will show you undone habits for today.") 864 | fmt.Println("Running harsh log will show you a consistency graph of your efforts.") 865 | fmt.Println(" (the graph gets way cooler looking over time.") 866 | fmt.Println("For more depth, you can read https://github.com/wakatara/harsh#usage") 867 | fmt.Println("") 868 | fmt.Println("Happy tracking! I genuinely hope this helps you with your goals. Buena suerte!") 869 | os.Exit(0) 870 | } 871 | 872 | // first time ask is used and log empty asks user how far back to track 873 | func onboard() int { 874 | fmt.Println("Your log file looks empty. Let's setup your tracking.") 875 | fmt.Println("How many days back shall we start tracking from in days?") 876 | fmt.Println("harsh will ask you about each habit for every day back.") 877 | fmt.Println("Starting today would be 0. Choose. (0-7) ") 878 | var numberOfDays int 879 | for { 880 | reader := bufio.NewReader(os.Stdin) 881 | dayResult, err := reader.ReadString('\n') 882 | if err != nil { 883 | fmt.Fprintln(os.Stderr, err) 884 | } 885 | 886 | dayResult = strings.TrimSpace(dayResult) 887 | dayNum, err := strconv.Atoi(dayResult) 888 | if err == nil { 889 | if dayNum >= 0 && dayNum <= 7 { 890 | numberOfDays = dayNum 891 | break 892 | } 893 | } 894 | 895 | color.FgRed.Printf("Sorry! Please choose a valid number (0-7) ") 896 | } 897 | return numberOfDays 898 | } 899 | 900 | // createExampleHabitsFile writes a fresh Habits file for people to follow 901 | func createExampleHabitsFile(configDir string) { 902 | fileName := filepath.Join(configDir, "/habits") 903 | _, err := os.Stat(fileName) 904 | if os.IsNotExist(err) { 905 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 906 | os.MkdirAll(configDir, os.ModePerm) 907 | } 908 | f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 909 | if err != nil { 910 | log.Fatalf("error opening file: %v", err) 911 | } 912 | f.WriteString("# This is your habits file.\n") 913 | f.WriteString("# It tells harsh what to track and how frequently.\n") 914 | f.WriteString("# 1 means daily, 7 means weekly, 14 every two weeks.\n") 915 | f.WriteString("# You can also track targets within a set number of days.\n") 916 | f.WriteString("# For example, Gym 3 times a week would translate to 3/7.\n") 917 | f.WriteString("# 0 is for tracking a habit. 0 frequency habits will not warn or score.\n") 918 | f.WriteString("# Examples:\n\n") 919 | f.WriteString("Gymmed: 3/7\n") 920 | f.WriteString("Bed by midnight: 1\n") 921 | f.WriteString("Cleaned House: 7\n") 922 | f.WriteString("Called Mom: 7\n") 923 | f.WriteString("Tracked Finances: 15\n") 924 | f.WriteString("New Skill: 90\n") 925 | f.WriteString("Too much coffee: 0\n") 926 | f.WriteString("Used harsh: 0\n") 927 | f.Close() 928 | } 929 | } 930 | 931 | // createNewLogFile writes an empty log file for people to start tracking into 932 | func createNewLogFile(configDir string) { 933 | fileName := filepath.Join(configDir, "/log") 934 | _, err := os.Stat(fileName) 935 | if os.IsNotExist(err) { 936 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 937 | os.MkdirAll(configDir, os.ModePerm) 938 | } 939 | _, err := os.OpenFile(fileName, os.O_RDONLY|os.O_CREATE, 0644) 940 | if err != nil { 941 | log.Fatalf("error opening file: %v", err) 942 | } 943 | } 944 | } 945 | -------------------------------------------------------------------------------- /harsh_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "cloud.google.com/go/civil" 10 | ) 11 | 12 | func TestHabitParsing(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | freq string 16 | target int 17 | interval int 18 | }{ 19 | {"Daily habit", "1", 1, 1}, 20 | {"Weekly habit", "7", 1, 7}, 21 | {"Three times weekly", "3/7", 3, 7}, 22 | {"Tracking only", "0", 0, 1}, 23 | {"Monthly habit", "30", 1, 30}, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | h := &Habit{Name: "Test", Frequency: tt.freq} 29 | h.parseHabitFrequency() 30 | if h.Target != tt.target || h.Interval != tt.interval { 31 | t.Errorf("got target=%d interval=%d, want target=%d interval=%d", 32 | h.Target, h.Interval, tt.target, tt.interval) 33 | } 34 | }) 35 | } 36 | } 37 | 38 | func TestSatisfied(t *testing.T) { 39 | tests := []struct { 40 | name string 41 | d civil.Date 42 | habit Habit 43 | entries Entries 44 | want bool 45 | }{ 46 | { 47 | name: "Target = 1, Interval = 1 (should always fail)", 48 | d: civil.Date{Year: 2025, Month: 3, Day: 24}, 49 | habit: Habit{Name: "Daily Walk", Target: 1, Interval: 1}, 50 | entries: Entries{ 51 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 24}, Habit: "Daily Walk"}: {Result: "y"}, 52 | }, 53 | want: false, 54 | }, 55 | { 56 | name: "Target = 1, Interval = 7 (meets target - valid streak)", 57 | d: civil.Date{Year: 2025, Month: 3, Day: 15}, 58 | habit: Habit{Name: "Habit", Target: 1, Interval: 7}, 59 | entries: Entries{ 60 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 14}, Habit: "Habit"}: {Result: "y"}, 61 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 21}, Habit: "Habit"}: {Result: "y"}, 62 | }, 63 | want: true, // Habit satisfied in the last 7 days (14 → 21) 64 | }, 65 | { 66 | name: "Target = 1, Interval = 7 (streak is broken, does not meet target)", 67 | d: civil.Date{Year: 2025, Month: 3, Day: 23}, 68 | habit: Habit{Name: "Habit", Target: 1, Interval: 7}, 69 | entries: Entries{ 70 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 16}, Habit: "Habit"}: {Result: "y"}, 71 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 24}, Habit: "Habit"}: {Result: "y"}, 72 | }, 73 | want: true, // Streak was broken (March 16) before the last valid "y" (March 24) 74 | }, 75 | { 76 | name: "Target = 1, Interval = 7 (no streak at all)", 77 | d: civil.Date{Year: 2025, Month: 2, Day: 21}, 78 | habit: Habit{Name: "Habit", Target: 1, Interval: 7}, 79 | entries: Entries{ 80 | DailyHabit{Day: civil.Date{Year: 2025, Month: 2, Day: 9}, Habit: "Habit"}: {Result: "y"}, 81 | DailyHabit{Day: civil.Date{Year: 2025, Month: 2, Day: 17}, Habit: "Habit"}: {Result: "y"}, 82 | DailyHabit{Day: civil.Date{Year: 2025, Month: 2, Day: 25}, Habit: "Habit"}: {Result: "y"}, 83 | }, 84 | want: true, // No "y" in the last 7 days before Feb 21, and previous streak is broken 85 | }, 86 | 87 | { 88 | name: "Target = 2, Interval = 7 (meets target)", 89 | d: civil.Date{Year: 2025, Month: 3, Day: 26}, 90 | habit: Habit{Name: "Bike 10k", Target: 2, Interval: 7}, 91 | entries: Entries{ 92 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 25}, Habit: "Bike 10k"}: {Result: "y"}, 93 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 28}, Habit: "Bike 10k"}: {Result: "y"}, 94 | }, 95 | want: true, 96 | }, 97 | { 98 | name: "Target = 2, Interval = 7 (does not meet target)", 99 | d: civil.Date{Year: 2025, Month: 3, Day: 27}, 100 | habit: Habit{Name: "Bike 10k", Target: 2, Interval: 7}, 101 | entries: Entries{ 102 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 23}, Habit: "Bike 10k"}: {Result: "y"}, 103 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 30}, Habit: "Bike 10k"}: {Result: "y"}, 104 | }, 105 | want: false, 106 | }, 107 | { 108 | name: "Target = 4, Interval = 7 (does not meet target)", 109 | d: civil.Date{Year: 2025, Month: 3, Day: 24}, 110 | habit: Habit{Name: "Run 5k", Target: 4, Interval: 7}, 111 | entries: Entries{ 112 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 20}, Habit: "Run 5k"}: {Result: "y"}, 113 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 22}, Habit: "Run 5k"}: {Result: "y"}, 114 | }, 115 | want: false, 116 | }, 117 | { 118 | name: "Target = 7, Interval = 10 (meets target)", 119 | d: civil.Date{Year: 2025, Month: 3, Day: 24}, 120 | habit: Habit{Name: "Swim", Target: 7, Interval: 10}, 121 | entries: Entries{ 122 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 15}, Habit: "Swim"}: {Result: "y"}, 123 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 16}, Habit: "Swim"}: {Result: "y"}, 124 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 17}, Habit: "Swim"}: {Result: "y"}, 125 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 18}, Habit: "Swim"}: {Result: "y"}, 126 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 19}, Habit: "Swim"}: {Result: "y"}, 127 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 20}, Habit: "Swim"}: {Result: "y"}, 128 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 23}, Habit: "Swim"}: {Result: "y"}, 129 | }, 130 | want: true, 131 | }, 132 | { 133 | name: "Target = 10, Interval = 14 (meets target)", 134 | d: civil.Date{Year: 2025, Month: 3, Day: 24}, 135 | habit: Habit{Name: "Yoga", Target: 10, Interval: 14}, 136 | entries: Entries{ 137 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 11}, Habit: "Yoga"}: {Result: "y"}, 138 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 12}, Habit: "Yoga"}: {Result: "y"}, 139 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 13}, Habit: "Yoga"}: {Result: "y"}, 140 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 14}, Habit: "Yoga"}: {Result: "y"}, 141 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 15}, Habit: "Yoga"}: {Result: "y"}, 142 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 16}, Habit: "Yoga"}: {Result: "y"}, 143 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 17}, Habit: "Yoga"}: {Result: "y"}, 144 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 18}, Habit: "Yoga"}: {Result: "y"}, 145 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 19}, Habit: "Yoga"}: {Result: "y"}, 146 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 22}, Habit: "Yoga"}: {Result: "y"}, 147 | }, 148 | want: true, 149 | }, 150 | { 151 | name: "Target = 3, Interval = 28 (does not meet target)", 152 | d: civil.Date{Year: 2025, Month: 3, Day: 24}, 153 | habit: Habit{Name: "Strength Training", Target: 3, Interval: 28}, 154 | entries: Entries{ 155 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 5}, Habit: "Strength Training"}: {Result: "y"}, 156 | DailyHabit{Day: civil.Date{Year: 2025, Month: 3, Day: 15}, Habit: "Strength Training"}: {Result: "y"}, 157 | }, 158 | want: false, 159 | }, 160 | } 161 | 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | got := satisfied(tt.d, &tt.habit, tt.entries) 165 | if got != tt.want { 166 | t.Errorf("satisfied() = %v, want %v", got, tt.want) 167 | } 168 | }) 169 | } 170 | } 171 | 172 | func TestScore(t *testing.T) { 173 | h := &Harsh{ 174 | Habits: []*Habit{ 175 | {Name: "Test1", Target: 1, Interval: 1}, 176 | {Name: "Test2", Target: 1, Interval: 1}, 177 | {Name: "Test3", Target: 1, Interval: 1}, 178 | {Name: "Test4", Target: 1, Interval: 1}, 179 | }, 180 | Entries: &Entries{}, 181 | } 182 | 183 | today := civil.DateOf(time.Now()) 184 | (*h.Entries)[DailyHabit{Day: today, Habit: "Test1"}] = Outcome{Result: "y"} 185 | (*h.Entries)[DailyHabit{Day: today, Habit: "Test2"}] = Outcome{Result: "y"} 186 | (*h.Entries)[DailyHabit{Day: today, Habit: "Test3"}] = Outcome{Result: "n"} 187 | (*h.Entries)[DailyHabit{Day: today, Habit: "Test4"}] = Outcome{Result: "y"} 188 | 189 | score := h.score(today) 190 | if score != 75.0 { 191 | t.Errorf("Expected score 75.0, got %f", score) 192 | } 193 | } 194 | 195 | func TestConfigFileCreation(t *testing.T) { 196 | // Create temporary directory for test 197 | tmpDir, err := os.MkdirTemp("", "harsh_test") 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | defer os.RemoveAll(tmpDir) 202 | 203 | // Test habits file creation 204 | createExampleHabitsFile(tmpDir) 205 | if _, err := os.Stat(filepath.Join(tmpDir, "habits")); os.IsNotExist(err) { 206 | t.Error("Habits file was not created") 207 | } 208 | 209 | // Test log file creation 210 | createNewLogFile(tmpDir) 211 | if _, err := os.Stat(filepath.Join(tmpDir, "log")); os.IsNotExist(err) { 212 | t.Error("Log file was not created") 213 | } 214 | } 215 | 216 | func TestBuildGraph(t *testing.T) { 217 | entries := &Entries{} 218 | h := &Harsh{ 219 | CountBack: 7, 220 | Entries: entries, 221 | } 222 | 223 | habit := &Habit{ 224 | Name: "Test", 225 | Target: 1, 226 | Interval: 1, 227 | } 228 | 229 | today := civil.DateOf(time.Now()) 230 | tom := today.AddDays(1) 231 | (*entries)[DailyHabit{Day: today, Habit: "Test"}] = Outcome{Result: "y"} 232 | (*entries)[DailyHabit{Day: tom, Habit: "Test"}] = Outcome{Result: "y"} 233 | 234 | graph := h.buildGraph(habit, false) 235 | length := len(graph) 236 | // Due to using Builder.Grow for string efficiency, this is 10, not 8 237 | if length != 10 { 238 | t.Errorf("Expected graph length 10, got %d", length) 239 | } 240 | } 241 | 242 | func TestWarning(t *testing.T) { 243 | entries := Entries{} 244 | today := civil.DateOf(time.Now()) 245 | 246 | habit := &Habit{ 247 | Name: "Test", 248 | Target: 1, 249 | Interval: 7, 250 | FirstRecord: today.AddDays(-10), 251 | } 252 | 253 | if !warning(today, habit, entries) { 254 | t.Error("Expected warning for habit with no entries") 255 | } 256 | 257 | entries[DailyHabit{Day: today, Habit: "Test"}] = Outcome{Result: "y"} 258 | if warning(today, habit, entries) { 259 | t.Error("Expected no warning after completing habit") 260 | } 261 | } 262 | 263 | func TestNewHabitIntegration(t *testing.T) { 264 | // Create temporary directory for test 265 | tmpDir, err := os.MkdirTemp("", "harsh_test_integration") 266 | if err != nil { 267 | t.Fatal(err) 268 | } 269 | defer os.RemoveAll(tmpDir) 270 | 271 | // Save original config dir and restore it after test 272 | originalConfigDir := configDir 273 | configDir = tmpDir 274 | defer func() { configDir = originalConfigDir }() 275 | 276 | // Create initial habits file 277 | habitsFile := filepath.Join(tmpDir, "habits") 278 | err = os.WriteFile(habitsFile, []byte("# Daily habits\nExisting habit: 1\n"), 0644) 279 | if err != nil { 280 | t.Fatal(err) 281 | } 282 | 283 | // Create empty log file 284 | logFile := filepath.Join(tmpDir, "log") 285 | err = os.WriteFile(logFile, []byte(""), 0644) 286 | if err != nil { 287 | t.Fatal(err) 288 | } 289 | 290 | // Load initial configuration 291 | harsh := newHarsh() 292 | 293 | // Verify initial state 294 | if len(harsh.Habits) != 1 { 295 | t.Errorf("Expected 1 habit, got %d", len(harsh.Habits)) 296 | } 297 | 298 | // Add new habit to habits file 299 | f, err := os.OpenFile(habitsFile, os.O_APPEND|os.O_WRONLY, 0644) 300 | if err != nil { 301 | t.Fatal(err) 302 | } 303 | _, err = f.WriteString("\nNew habit: 1\n") 304 | if err != nil { 305 | t.Fatal(err) 306 | } 307 | err = f.Close() 308 | if err != nil { 309 | t.Fatal(err) 310 | } 311 | 312 | // Reload configuration 313 | harsh = newHarsh() 314 | 315 | // Verify new habit was loaded 316 | if len(harsh.Habits) != 2 { 317 | t.Errorf("Expected 2 habits, got %d", len(harsh.Habits)) 318 | } 319 | 320 | foundNewHabit := false 321 | for _, h := range harsh.Habits { 322 | if h.Name == "New habit" { 323 | foundNewHabit = true 324 | break 325 | } 326 | } 327 | if !foundNewHabit { 328 | t.Error("New habit was not found in loaded habits") 329 | } 330 | 331 | // Test that new habit appears in todos 332 | today := civil.DateOf(time.Now()) 333 | todos := harsh.getTodos(today, 0) 334 | 335 | foundInTodos := false 336 | for todo := range todos { 337 | if todo == "New habit" { 338 | foundInTodos = true 339 | break 340 | } 341 | } 342 | if !foundInTodos { 343 | t.Error("New habit was not found in todos") 344 | } 345 | 346 | // Test that new habit would be included in ask 347 | // We can't directly test the CLI interaction, but we can verify the habit 348 | // would be included in the list of habits to ask about 349 | undone := harsh.getTodos(today, 0) 350 | 351 | foundInUndone := false 352 | for h := range undone { 353 | if h == "New habit" { 354 | foundInUndone = true 355 | break 356 | } 357 | } 358 | if !foundInUndone { 359 | t.Error("New habit was not found in undone habits (would not be asked about)") 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export OWNER=wakatara 4 | export REPO=harsh 5 | export SUCCESS_CMD="$REPO --version" 6 | export BINLOCATION="/usr/local/bin" 7 | 8 | version=$(curl -sI https://github.com/$OWNER/$REPO/releases/latest | grep -i "location:" | awk -F"/" '{ printf "%s", $NF }' | tr -d '\r') 9 | 10 | if [ ! $version ]; then 11 | echo "Failed while attempting to install $REPO. Please manually install:" 12 | echo "" 13 | echo "1. Open your web browser and go to https://github.com/$OWNER/$REPO/releases" 14 | echo "2. Download the latest release for your platform. Call it '$REPO'." 15 | echo "3. chmod +x ./$REPO" 16 | echo "4. mv ./$REPO $BINLOCATION" 17 | exit 1 18 | fi 19 | 20 | hasCli() { 21 | 22 | hasCurl=$(which curl) 23 | if [ "$?" = "1" ]; then 24 | echo "You need curl to use this script." 25 | exit 1 26 | fi 27 | } 28 | 29 | getPackage() { 30 | uname=$(uname) 31 | userid=$(id -u) 32 | 33 | suffix="" 34 | case $uname in 35 | "Darwin") 36 | # suffix=".tar.gz" 37 | arch=$(uname -m) 38 | case $arch in 39 | "x86_64") 40 | suffix="Darwin_x86_64.tar.gz" 41 | ;; 42 | esac 43 | case $arch in 44 | "aarch64") 45 | suffix="Darwin_arm64.tar.gz" 46 | ;; 47 | esac 48 | ;; 49 | "Linux") 50 | arch=$(uname -m) 51 | echo $arch 52 | case $arch in 53 | "x86_64") 54 | suffix="Linux_x86_64.tar.gz" 55 | ;; 56 | esac 57 | case $arch in 58 | "i386") 59 | suffix="Linux_i386.tar.gz" 60 | ;; 61 | esac 62 | case $arch in 63 | "aarch64") 64 | suffix="Linux_arm64.tar.gz" 65 | ;; 66 | esac 67 | case $arch in 68 | "armv6l" | "armv7l") 69 | suffix="Linux_armv6.tar.gz" 70 | ;; 71 | esac 72 | ;; 73 | esac 74 | 75 | targetFile="/tmp/${REPO}_${suffix}" 76 | if [ "$userid" != "0" ]; then 77 | targetFile="$(pwd)/${REPO}_${suffix}" 78 | fi 79 | 80 | if [ -e "$targetFile" ]; then 81 | rm "$targetFile" 82 | fi 83 | 84 | url="https://github.com/$OWNER/$REPO/releases/download/$version/${REPO}_${suffix}" 85 | echo "Downloading package $url as $targetFile" 86 | 87 | curl -sSLf $url --output "$targetFile" 88 | 89 | if [ $? -ne 0 ]; then 90 | echo "Download Failed!" 91 | exit 1 92 | else 93 | extractFolder=$(pwd) 94 | echo "Download Complete, extracting $targetFile to $extractFolder ..." 95 | tar -xzf "$targetFile" -C "$extractFolder" 96 | fi 97 | 98 | if [ $? -ne 0 ]; then 99 | echo "\nFailed to expand archve: $targetFile" 100 | exit 1 101 | else 102 | # Remove the LICENSE and README 103 | echo "OK" 104 | rm "$(pwd)/LICENSE" 105 | rm "$(pwd)/README.md" 106 | 107 | # Get the parent dir of the 'bin' folder holding the binary 108 | # targetFile=$(echo "$targetFile" | sed "s+/${REPO}${suffix}++g") 109 | # suffix=$(echo $suffix | sed 's/.tgz//g') 110 | 111 | # installFile="${targetFile}/${REPO}/harsh" 112 | installFile="$(pwd)/harsh" 113 | 114 | chmod +x "$installFile" 115 | 116 | # Calculate SHA 117 | # https://github.com/wakatara/harsh/releases/download/v0.8.12/checksums.txt 118 | shaurl="https://github.com/$OWNER/$REPO/releases/download/$version/checksums.txt" 119 | shacheck="$(curl -sSLf $shaurl | grep ${REPO}_${suffix})" 120 | SHA256="$(echo $shacheck | awk '{print $1}')" 121 | echo "SHA256 fetched from release: $SHA256" 122 | # NOTE to other maintainers 123 | # There needs to be two spaces between the SHA and the file in the echo statement 124 | # for shasum to compare the checksums 125 | echo "$SHA256 $targetFile" | shasum -a 256 -c -s 126 | 127 | # Don't need the tar.gz any more so delete it 128 | rm "$targetFile" 129 | 130 | if [ $? -ne 0 ]; then 131 | echo "SHA mismatch! This means there must be a problem with the download" 132 | exit 1 133 | else 134 | if [ ! -w "$BINLOCATION" ]; then 135 | echo 136 | echo "============================================================" 137 | echo " The script was run as a user who is unable to write" 138 | echo " to $BINLOCATION. To complete the installation the" 139 | echo " following commands may need to be run manually." 140 | echo "============================================================" 141 | echo 142 | echo " sudo mv $installFile $BINLOCATION/$REPO" 143 | echo 144 | ./${REPO} --version 145 | else 146 | 147 | echo 148 | echo "SHA 256 integrity check on release ${version} succesful" 149 | echo "Running with sufficient permissions to attempt to move $REPO to $BINLOCATION" 150 | 151 | mv "$installFile" $BINLOCATION/$REPO 152 | 153 | if [ "$?" = "0" ]; then 154 | echo "New version of $REPO installed to $BINLOCATION" 155 | fi 156 | 157 | if [ -e "$installFile" ]; then 158 | rm "$installFile" 159 | fi 160 | echo "Checking successful install: running 'harsh --version'" 161 | ${SUCCESS_CMD} 162 | fi 163 | fi 164 | fi 165 | } 166 | 167 | hasCli 168 | getPackage 169 | 170 | --------------------------------------------------------------------------------