├── .github └── workflows │ └── build.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.zig ├── screenshot.png └── src ├── color.zig ├── date.zig ├── help.zig ├── main.zig ├── test.zig └── tui.zig /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | 5 | concurrency: 6 | group: ${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest] 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | steps: 18 | - name: Set up Zig 19 | uses: goto-bus-stop/setup-zig@v2.2.0 20 | with: 21 | version: 0.11.0 22 | 23 | - name: Check out repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Zig build test Debug 27 | run: | 28 | zig build test 29 | 30 | - name: Zig build test Release 31 | run: | 32 | zig build test -Drelease 33 | 34 | upload: 35 | runs-on: ubuntu-latest 36 | needs: build 37 | steps: 38 | - name: Set up Zig 39 | uses: goto-bus-stop/setup-zig@v2.2.0 40 | with: 41 | version: 0.11.0 42 | 43 | - name: Check out repository 44 | uses: actions/checkout@v4 45 | 46 | - name: Zig build cross 47 | run: | 48 | zig env 49 | zig build cross -Drelease 50 | 51 | - name: Upload artifacts 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: bin 55 | path: zig-out/bin/habu-* 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 20240302 2 | 3 | #### Bugfixes 4 | 5 | - Fix edge case date handling 6 | 7 | ### 20240127 8 | 9 | #### Bugfixes 10 | 11 | - Fix memory alignment issue that sometimes happened with empty databases: https://github.com/schmee/habu/issues/5 12 | 13 | ### 20240109 14 | 15 | #### Bugfixes 16 | 17 | - Fix week number calculation when transitioning from one year to the next. 18 | - Fix date parsing when using the `nth` syntax and selecting a date in a previous year. 19 | - Fix bug that would sometimes cause weekly chains spanning a year transition to appear broken. 20 | - Fix chain `info` stats for chains spanning multiple years. 21 | 22 | ### 20231126 23 | 24 | - Add new date formats 25 | - Nth, Nst, Nrd, Nnd: Nth day of the current month is N >= today, else Nth day of previous month 26 | - Weekdays: mon/monday, tue/tuesday... 27 | - N: N days ago (1, 2, 3...) 28 | 29 | ### 20231117 30 | 31 | #### Windows support 32 | 33 | Habu now works on Windows! Note that I'm not a daily Windows user myself, so I'm happy to hear about any bugs or weird behavior Windows users come across. 34 | 35 | #### Add `stopped` modifier to chains. 36 | 37 | Stopping a chain is useful when you are no longer interested in tracking it but you want to keep the history and stats around. 38 | Use `modify stopped ` to stop a chain, or `modify stopped false` to make a stopped chain active. 39 | Stopped chains are not displayed by default, and their stats only include the dates from the creation date up to the stopped date. 40 | Also add a new `--show` parameter to choose which chains to display. 41 | 42 | #### Minor features 43 | 44 | - Add `habu version` command. 45 | 46 | #### Bugfixes 47 | 48 | - Compute stats for the entire chain in `info` command, not just the last 30 days. 49 | - Fix args check for `habu help `, it takes 1 optional arg, not 0. 50 | 51 | ### 20230810 52 | 53 | - Initial release 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 John Schmidt 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Habu 2 | 3 | Habu is a TUI habit tracker inspired by [Taskwarrior](https://taskwarrior.org/) and [Timewarrior](https://timewarrior.net/). 4 | 5 | It's inspired by the "Don't break the chain" method (attributed to Jerry Seinfeld) that encourages forming habits by performing the task every day and eventually building up a long "chain" of consecutive days. Seeing the chain grow over time is a powerful reminder of your progress and motivates sticking to it: don't break the chain! 6 | 7 |

8 | 9 |

10 | 11 | ## Installation 12 | 13 | Grab the latest build from the [releases](https://github.com/schmee/habu/releases/), or [build it yourself](#building)! 14 | 15 | ## Building 16 | 17 | Habu runs on macOS, Linux and Windows. 18 | 19 | 1. Download Zig 0.11.0 for your platform from https://ziglang.org/download/. 20 | 1. Clone this repo. 21 | 1. Run `zig build install -Drelease`. 22 | 1. Place `zig-out/bin/habu` somewhere on your path. 23 | 24 | ## Note on stability 25 | 26 | Habu is stable enough for daily use, but it should still be considered experimental. It is recommended to back up your `.habu` directory regularly, especially before upgrading to a new version. 27 | 28 | Breaking changes are avoided whenever possible for both the data format and the CLI. If they do occur, they will be always be pointed out explicitly in the CHANGELOG and include instructions on how to upgrade. 29 | 30 | ## Getting started 31 | 32 | Let's walk through how to track a daily habit: 33 | 34 | 1. Add a chain with `habu add`: 35 | - To add a daily habit, use `habu add daily` 36 | - To add a weekly habit, use `habu add weekly ` 37 | - The last number sets how many days per week the habit must be performed to be considered unbroken 38 | 39 | After adding a chain, all your chains are displayed on a calender-like grid. This grid can be viewed with either `habu display` or just `habu` without arguments. The number before the chain name is the _chain index_, which is used in most commands to refer to a specific chain. 40 | 41 | ``` 42 | 8 43 | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 1 44 | mon tue wed thu fri sat sun mon tue wed thu fri sat sun mon tue 45 | (1) Exercise 46 | ^--- this is the chain index 47 | ``` 48 | 49 | 50 | 2. To mark today's exercise as completed, use `habu link 1`: 51 | 52 | ``` 53 | 8 54 | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 1 55 | mon tue wed thu fri sat sun mon tue wed thu fri sat sun mon tue 56 | (1) Exercise ██ 57 | link added --------^^ 58 | ``` 59 | 60 | 3. Let's mark yesterday as well with `habu link 1 yesterday` (`habu link 1 y` for short): 61 | 62 | ``` 63 | 8 64 | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 1 65 | mon tue wed thu fri sat sun mon tue wed thu fri sat sun mon tue 66 | (1) Exercise ██━━██ 67 | building up the chain! -------^^ 68 | ``` 69 | 70 | That's it! You can use `habu help` to list the available commands and some additional explanation of the basic concepts. 71 | 72 | Tracking your habits in this way is a means to an end: the goal is not to add a tick in the "Exercise" or "Writing" chains, it is to get in better shape and write the book you've always dreamed about. I've found that tracking my habits in this way helps me avoid procrastinating and stay focused on the things that really matter to me, but I certainly don't expect it to appeal to or work for everyone. Experiment and see what works best for you! 73 | 74 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) !void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{ 6 | .preferred_optimize_mode = .ReleaseSafe 7 | }); 8 | const root_source_file = .{ .path = "src/main.zig" }; 9 | const exe = b.addExecutable(.{ 10 | .name = "habu", 11 | .root_source_file = root_source_file, 12 | .target = target, 13 | .optimize = optimize, 14 | .link_libc = true, 15 | }); 16 | 17 | b.installArtifact(exe); 18 | 19 | const build_opts = b.addOptions(); 20 | exe.addOptions("build_options", build_opts); 21 | 22 | const version = "dev"; 23 | build_opts.addOption([]const u8, "version", version); 24 | 25 | const git_commit_hash = b.exec(&.{"git", "rev-parse", "HEAD"}); 26 | build_opts.addOption([]const u8, "git_commit_hash", git_commit_hash[0..git_commit_hash.len - 1]); // Skip ending newline 27 | 28 | const cross_step = b.step("cross", "Install cross-compiled executables"); 29 | 30 | inline for (triples) |triple| { 31 | const cross = b.addExecutable(.{ 32 | .name = "habu-" ++ triple, 33 | .root_source_file = root_source_file, 34 | .optimize = optimize, 35 | .target = try std.zig.CrossTarget.parse(.{ .arch_os_abi = triple }), 36 | .link_libc = true, 37 | }); 38 | cross.addOptions("build_options", build_opts); 39 | cross.strip = true; 40 | const cross_install = b.addInstallArtifact(cross, .{}); 41 | cross_step.dependOn(&cross_install.step); 42 | } 43 | 44 | const run_cmd = b.addRunArtifact(exe); 45 | 46 | run_cmd.step.dependOn(b.getInstallStep()); 47 | 48 | if (b.args) |args| { 49 | run_cmd.addArgs(args); 50 | } 51 | 52 | const run_step = b.step("run", "Run the app"); 53 | run_step.dependOn(&run_cmd.step); 54 | 55 | const test_step = b.step("test", "Run tests"); 56 | const tests = b.addTest(.{ 57 | .root_source_file = .{ .path = "src/main.zig" }, 58 | .optimize = optimize, 59 | .target = target, 60 | .link_libc = true, 61 | }); 62 | const run_tests = b.addRunArtifact(tests); 63 | run_tests.step.dependOn(b.getInstallStep()); 64 | test_step.dependOn(&run_tests.step); 65 | } 66 | 67 | const triples = .{ 68 | "x86_64-linux-gnu", 69 | "aarch64-macos-none", 70 | "x86_64-macos-none", 71 | "x86_64-windows-gnu", 72 | }; 73 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schmee/habu/7501f338523a2c86f9fe70d2948bba86ececfd6a/screenshot.png -------------------------------------------------------------------------------- /src/color.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const sand = Rgb{ .r = 222, .g = 211, .b = 195 }; 4 | pub const white = Rgb{ .r = 255, .g = 255, .b = 255 }; 5 | 6 | pub const Rgb = extern struct { 7 | r: u8, 8 | g: u8, 9 | b: u8, 10 | 11 | const Self = @This(); 12 | 13 | pub fn toHex(self: Self) [7]u8 { 14 | var buf: [7]u8 = undefined; 15 | buf[0] = '#'; 16 | _ = std.fmt.bufPrint(buf[1..], "{x:0>2}{x:0>2}{x:0>2}", .{ self.r, self.g, self.b }) catch unreachable; 17 | return buf; 18 | } 19 | 20 | pub fn fromHex(str: []const u8) !Rgb { 21 | if (str.len != 6) return error.InvalidLength; 22 | return .{ 23 | .r = try std.fmt.parseInt(u8, str[0..2], 16), 24 | .g = try std.fmt.parseInt(u8, str[2..4], 16), 25 | .b = try std.fmt.parseInt(u8, str[4..6], 16), 26 | }; 27 | } 28 | }; 29 | 30 | pub const colors = blk: { 31 | const hex_codes = [_]*const[6]u8{ 32 | "7e7af5", // 1 33 | "ec3dc0", // 3 34 | "c67533", // 5 35 | "509a28", // 7 36 | "289988", // 9 37 | "bb5df4", // 2 38 | "f34b62", // 4 39 | "898f25", // 6 40 | "299d46", // 8 41 | "378ed5", // 10 42 | }; 43 | var cs: [hex_codes.len]Rgb = undefined; 44 | for (hex_codes, &cs) |s, *c| { 45 | c.* = Rgb.fromHex(s) catch unreachable; 46 | } 47 | break :blk cs; 48 | }; 49 | 50 | 51 | // Generated by https://huey.design/ 52 | // Starting color: 483FF3, Hue families: 12, Tints & Shades: 6 53 | // $blue-0: #d3d2fb; 54 | // $blue-1: #a9a7f8; 55 | // $blue-2: #7e7af5; 56 | // $blue-3: #5149f3; 57 | // $blue-4: #332dae; 58 | // $blue-5: #1d1960; 59 | // $purple-0: #e5cdfa; 60 | // $purple-1: #cf99f7; 61 | // $purple-2: #bb5df4; 62 | // $purple-3: #9534c9; 63 | // $purple-4: #642387; 64 | // $purple-5: #37134b; 65 | // $fuschia-0: #fac7e9; 66 | // $fuschia-1: #f68ad5; 67 | // $fuschia-2: #ec3dc0; 68 | // $fuschia-3: #af2d8f; 69 | // $fuschia-4: #761e60; 70 | // $fuschia-5: #421136; 71 | // $amaranth-0: #facace; 72 | // $amaranth-1: #f6929c; 73 | // $amaranth-2: #f34b62; 74 | // $amaranth-3: #ba3045; 75 | // $amaranth-4: #7d202e; 76 | // $amaranth-5: #45121a; 77 | // $orange-0: #f9cdb7; 78 | // $orange-1: #f49957; 79 | // $orange-2: #c67533; 80 | // $orange-3: #925626; 81 | // $orange-4: #623a19; 82 | // $orange-5: #351f0e; 83 | // $yellow-0: #d6de3a; 84 | // $yellow-1: #afb62f; 85 | // $yellow-2: #898f25; 86 | // $yellow-3: #666a1b; 87 | // $yellow-4: #454712; 88 | // $yellow-5: #26280a; 89 | // $pistachio-0: #7cf03e; 90 | // $pistachio-1: #66c433; 91 | // $pistachio-2: #509a28; 92 | // $pistachio-3: #3b721e; 93 | // $pistachio-4: #274c14; 94 | // $pistachio-5: #162a0b; 95 | // $malachite_green-0: #4df373; 96 | // $malachite_green-1: #34c859; 97 | // $malachite_green-2: #299d46; 98 | // $malachite_green-3: #1e7433; 99 | // $malachite_green-4: #144e23; 100 | // $malachite_green-5: #0b2b13; 101 | // $opal-0: #3eeed4; 102 | // $opal-1: #32c3ad; 103 | // $opal-2: #289988; 104 | // $opal-3: #1d7165; 105 | // $opal-4: #144c44; 106 | // $opal-5: #0b2a25; 107 | // $azure-0: #bfd8f9; 108 | // $azure-1: #73b3f5; 109 | // $azure-2: #378ed5; 110 | // $azure-3: #29699d; 111 | // $azure-4: #1b4669; 112 | // $azure-5: #0f263a; 113 | -------------------------------------------------------------------------------- /src/date.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const epoch = std.time.epoch; 4 | const windows = std.os.windows; 5 | const expectEqual = std.testing.expectEqual; 6 | 7 | const Allocator = std.mem.Allocator; 8 | 9 | pub const secs_per_day: i64 = 60 * 60 * 24; 10 | pub const max_weeks_per_year: u8 = 52; 11 | 12 | pub const Weekday = enum(u3) { 13 | mon = 0, 14 | tue, 15 | wed, 16 | thu, 17 | fri, 18 | sat, 19 | sun, 20 | }; 21 | 22 | pub const weekday_names = [_][]const u8{ 23 | "monday", 24 | "tuesday", 25 | "wednesday", 26 | "thursday", 27 | "friday", 28 | "saturday", 29 | "sunday", 30 | }; 31 | 32 | const month_names = [_][]const u8{ 33 | "January", 34 | "February", 35 | "March", 36 | "April", 37 | "May", 38 | "June", 39 | "July", 40 | "August", 41 | "September", 42 | "October", 43 | "November", 44 | "December", 45 | }; 46 | 47 | const start_year: i64 = 2022; 48 | const weekday_20220101: Weekday = .sat; 49 | const instant_20220101: i64 = 1640995200; 50 | 51 | var tz_init: bool = false; 52 | var transitions: ?[]const Transition = null; 53 | 54 | pub fn getWeekdayFromEpoch(instant: i64) Weekday { 55 | std.debug.assert(instant > instant_20220101); 56 | const n_days = @divFloor(instant - instant_20220101, secs_per_day); 57 | return @as(Weekday, @enumFromInt(@mod(n_days - 2, 7))); 58 | } 59 | 60 | pub fn getWeekNumberFromEpoch(instant: i64) u64 { 61 | const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @as(u64, @intCast(instant)) }; 62 | const epoch_day = epoch_seconds.getEpochDay(); 63 | const year_day = epoch_day.calculateYearDay(); 64 | const weekday: u64 = @intFromEnum(getWeekdayFromEpoch(instant)); 65 | return @divFloor(year_day.day + 7 - (weekday % 7), 7); 66 | } 67 | 68 | pub const LocalDate = struct { 69 | year: u16, 70 | day: u8, 71 | month: u8, 72 | local: bool, // TODO: packed struct 73 | 74 | const Self = @This(); 75 | 76 | pub fn init(year: u16, month: u8, day: u8) !Self { 77 | if (year < 2022) return error.YearBefore2022; 78 | if (month < 1 or month > 12) return error.MonthOutOfRange; 79 | if (day > getDaysInMonth(year, month)) return error.DayOutOfRange; 80 | 81 | return .{ 82 | .year = year, 83 | .month = month, 84 | .day = day, 85 | .local = true, 86 | }; 87 | } 88 | 89 | pub fn parse(yyyymmdd: []const u8) !Self { 90 | if (yyyymmdd.len != 8) return error.BadFormat; 91 | 92 | const year = std.fmt.parseInt(u16, yyyymmdd[0..4], 10) catch |err| switch (err) { 93 | error.Overflow => unreachable, // all four-digit numbers fit into a u16 94 | else => |e| return e, 95 | }; 96 | 97 | const month = std.fmt.parseInt(u8, yyyymmdd[4..6], 10) catch |err| switch (err) { 98 | error.Overflow => unreachable, // all two-digit numbers fit into a u8 99 | else => |e| return e, 100 | }; 101 | 102 | const day = std.fmt.parseInt(u8, yyyymmdd[6..8], 10) catch |err| switch (err) { 103 | error.Overflow => unreachable, // all two-digit numbers fit into a u8 104 | else => |e| return e, 105 | }; 106 | 107 | return Self.init(year, month, day); 108 | } 109 | 110 | pub fn fromEpoch(instant: i64) LocalDate { 111 | const es = std.time.epoch.EpochSeconds{ .secs = @as(u64, @intCast(instant)) }; 112 | const yd = es.getEpochDay().calculateYearDay(); 113 | const md = yd.calculateMonthDay(); 114 | return .{ 115 | .year = yd.year, 116 | .month = md.month.numeric(), 117 | .day = md.day_index + 1, 118 | .local = false, 119 | }; 120 | } 121 | 122 | pub fn epochToLocal(instant: i64) LocalDate { 123 | var ld = Self.fromEpoch(instant + utcOffset(instant)); 124 | ld.local = true; 125 | return ld; 126 | } 127 | 128 | pub fn toEpoch(self: Self) i64 { 129 | var instant: i64 = 0; 130 | var y: u16 = 1970; 131 | while (y < self.year) : (y += 1) { 132 | instant += epoch.getDaysInYear(y) * secs_per_day; 133 | } 134 | var m: u8 = 1; 135 | while (m < self.month) : (m += 1) { 136 | instant += getDaysInMonth(self.year, m) * secs_per_day; 137 | } 138 | return instant + (self.day - 1) * secs_per_day; 139 | } 140 | 141 | pub fn midnightInLocal(self: Self) i64 { 142 | const instant = self.toEpoch(); 143 | return instant - utcOffset(instant); 144 | } 145 | 146 | pub fn asString(self: Self) [10]u8 { 147 | var buf: [10]u8 = undefined; 148 | _ = std.fmt.bufPrint(&buf, "{d}-{d:0>2}-{d:0>2}", .{ self.year, self.month, self.day }) catch unreachable; 149 | return buf; 150 | } 151 | 152 | pub fn yyyyMMdd(self: Self) [8]u8 { 153 | var buf: [8]u8 = undefined; 154 | _ = std.fmt.bufPrint(&buf, "{d}{d:0>2}{d:0>2}", .{ self.year, self.month, self.day }) catch unreachable; 155 | return buf; 156 | } 157 | 158 | pub fn compare(self: Self, other: Self) std.math.Order { 159 | return switch (std.math.order(self.year, other.year)) { 160 | .lt => .lt, 161 | .gt => .gt, 162 | .eq => switch (std.math.order(self.month, other.month)) { 163 | .lt => .lt, 164 | .gt => .gt, 165 | .eq => std.math.order(self.day, other.day), 166 | }, 167 | }; 168 | } 169 | 170 | pub fn next(self: Self) Self { 171 | var next_day = (self.day + 1) % (getDaysInMonth(self.year, self.month) + 1); 172 | if (next_day == 0) next_day += 1; 173 | var month = if (next_day < self.day) (self.month + 1) % 13 else self.month; 174 | if (month == 0) month += 1; 175 | const year = if (self.month == 12 and month == 1) self.year + 1 else self.year; 176 | const the_next = Self.init(year, month, next_day) catch unreachable; 177 | std.debug.assert(the_next.toEpoch() > self.toEpoch()); 178 | return the_next; 179 | } 180 | 181 | pub fn prev(self: Self) Self { 182 | var prev_day = (self.day - 1) % (getDaysInMonth(self.year, self.month) + 1); 183 | var month = self.month; 184 | if (prev_day == 0) { 185 | month = (self.month - 1) % 13; 186 | if (month == 0) month = 12; 187 | prev_day = getDaysInMonth(self.year, month); 188 | } 189 | const year = if (self.month == 1 and month == 12) self.year - 1 else self.year; 190 | const the_prev = Self.init(year, month, prev_day) catch unreachable; 191 | std.debug.assert(the_prev.toEpoch() < self.toEpoch()); 192 | return the_prev; 193 | } 194 | 195 | pub fn oneMonthAgo(self: Self) Self { 196 | const month = if (self.month == 1) 12 else self.month - 1; 197 | const year = if (self.month == 1 and month == 12) self.year - 1 else self.year; 198 | const day = @min(self.day, getDaysInMonth(year, month)); 199 | const one_month_ago = Self.init( year, month, day) catch unreachable; 200 | std.debug.assert(one_month_ago.toEpoch() < self.toEpoch()); 201 | return one_month_ago; 202 | } 203 | 204 | pub fn prevMonthAtDay(self: Self, day: u8) !Self { 205 | const month = if (self.month == 1) 12 else self.month - 1; 206 | const year = if (self.month == 1 and month == 12) self.year - 1 else self.year; 207 | const prev_month_at_day = try Self.init(year, month, day); 208 | std.debug.assert(prev_month_at_day.toEpoch() < self.toEpoch()); 209 | return prev_month_at_day; 210 | } 211 | 212 | pub fn atStartOfWeek(self: Self) Self { 213 | var instant = self.toEpoch(); 214 | const day_of_week = getWeekdayFromEpoch(instant); 215 | const ordinal = @intFromEnum(day_of_week); 216 | if (ordinal == 0) 217 | return self; 218 | return LocalDate.fromEpoch(instant - ordinal * secs_per_day); 219 | } 220 | }; 221 | 222 | pub fn getDaysInMonth(year: u16, month: u8) u8 { 223 | const leap_year_kind: epoch.YearLeapKind = if (epoch.isLeapYear(year)) 224 | .leap 225 | else 226 | .not_leap; 227 | const month_enum = @as(epoch.Month, @enumFromInt(month)); 228 | return epoch.getDaysInMonth(leap_year_kind, month_enum); 229 | } 230 | 231 | pub const LocalDateTime = struct { 232 | year: u16, 233 | month: u8, 234 | day: u8, 235 | hour: u8, 236 | minute: u8, 237 | second: u8, 238 | 239 | const Self = @This(); 240 | 241 | pub fn fromEpoch(instant: i64) Self { 242 | const es = std.time.epoch.EpochSeconds{ .secs = @as(u64, @intCast(instant)) }; 243 | const yd = es.getEpochDay().calculateYearDay(); 244 | const md = yd.calculateMonthDay(); 245 | const ds = es.getDaySeconds(); 246 | 247 | return .{ 248 | .year = yd.year, 249 | .month = md.month.numeric(), 250 | .day = md.day_index + 1, 251 | .hour = ds.getHoursIntoDay(), 252 | .minute = ds.getMinutesIntoHour(), 253 | .second = ds.getSecondsIntoMinute(), 254 | }; 255 | } 256 | 257 | pub fn asString(self: Self) [19]u8 { 258 | var buf: [19]u8 = undefined; 259 | _ = std.fmt.bufPrint(&buf, "{d}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}", .{ 260 | self.year, 261 | self.month, 262 | self.day, 263 | self.hour, 264 | self.minute, 265 | self.second, 266 | }) catch unreachable; 267 | return buf; 268 | } 269 | }; 270 | 271 | pub fn monthName(month: u8) []const u8 { 272 | return month_names[month - 1]; 273 | } 274 | 275 | pub fn epochAtStartOfDay(instant: i64) i64 { 276 | const es = std.time.epoch.EpochSeconds{ .secs = @as(u64, @intCast(instant)) }; 277 | const ds = es.getDaySeconds(); 278 | return @as(i64, @intCast(instant - ds.secs)); 279 | } 280 | 281 | pub fn daysBetween(a: i64, b: i64) u64 { 282 | std.debug.assert(b >= a); 283 | return @divFloor(@as(u64, @intCast(b - a)), secs_per_day) -| 1; 284 | } 285 | 286 | var constant_now: ?i64 = null; 287 | 288 | pub fn overrideNow(now: i64) void { 289 | constant_now = now; 290 | } 291 | 292 | pub fn epochNow() i64 { 293 | return if (constant_now) |now| 294 | now 295 | else 296 | std.time.timestamp(); 297 | } 298 | 299 | pub const Transition = struct { 300 | ts: i64, 301 | offset: i32, 302 | }; 303 | 304 | pub fn initTransitions(allocator: Allocator, transitions_str: ?[]const u8) !void { 305 | transitions = if (transitions_str) |str| 306 | try initTransitionsFromStr(allocator, str) 307 | else switch (builtin.os.tag) { 308 | .windows => try initTransitionsWindows(allocator), 309 | else => try initTransitionsPosix(allocator), 310 | }; 311 | tz_init = true; 312 | } 313 | 314 | fn initTransitionsFromStr(allocator: Allocator, transitions_str: []const u8) !?[]const Transition { 315 | var root = (try std.json.parseFromSliceLeaky(std.json.Value, allocator, transitions_str, .{})); 316 | var transitions_array = std.ArrayList(Transition).init(allocator); 317 | for (root.array.items) |v| { 318 | const obj = v.object; 319 | const transition = Transition{ 320 | .ts = @intCast(obj.get("ts").?.integer), 321 | .offset = @intCast(obj.get("offset").?.integer), 322 | }; 323 | try transitions_array.append(transition); 324 | } 325 | return try transitions_array.toOwnedSlice(); 326 | } 327 | 328 | fn initTransitionsPosix(allocator: Allocator) !?[]const Transition { 329 | defer tz_init = true; 330 | var db = getUserTimeZoneDb(allocator) catch |err| switch (err) { 331 | error.FileNotFound => return null, // Assume UTC 332 | else => return err, 333 | }; 334 | defer db.deinit(); 335 | var transitions_array = std.ArrayList(Transition).init(allocator); 336 | for (db.transitions) |t| { 337 | if (t.ts < instant_20220101) continue; 338 | try transitions_array.append(.{ 339 | .ts = t.ts, 340 | .offset = t.timetype.offset, 341 | }); 342 | } 343 | return try transitions_array.toOwnedSlice(); 344 | } 345 | 346 | fn initTransitionsWindows(allocator: Allocator) !?[]const Transition { 347 | var tz: TIME_DYNAMIC_ZONE_INFORMATION = undefined; 348 | const result = GetDynamicTimeZoneInformation(&tz); 349 | if (result == TIME_ZONE_ID_INVALID ) { 350 | return error.InvalidTimezone; 351 | } 352 | 353 | var local_time: SYSTEMTIME = undefined; 354 | GetLocalTime(&local_time); 355 | 356 | var year: u16 = @intCast(start_year); 357 | const end_year = local_time.year + 1; 358 | var transitions_array = std.ArrayList(Transition).init(allocator); 359 | while (year <= end_year) : (year += 1) { 360 | try transitions_array.append(transitionFromTz("standard", tz, year)); 361 | try transitions_array.append(transitionFromTz("daylight", tz, year)); 362 | } 363 | 364 | const S = struct { 365 | fn order(_: void, a: Transition, b: Transition) bool { 366 | return a.ts < b.ts; 367 | } 368 | }; 369 | std.sort.pdq(Transition, transitions_array.items, {}, S.order); 370 | return try transitions_array.toOwnedSlice(); 371 | } 372 | 373 | const TIME_ZONE_ID_INVALID: windows.DWORD = 0xffffffff; 374 | 375 | // https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-systemtime 376 | const SYSTEMTIME = extern struct { 377 | year: windows.WORD, 378 | month: windows.WORD, 379 | day_of_week: windows.WORD, 380 | day: windows.WORD, 381 | hour: windows.WORD, 382 | minute: windows.WORD, 383 | second: windows.WORD, 384 | milliseconds: windows.WORD, 385 | }; 386 | 387 | // https://learn.microsoft.com/en-us/windows/win32/api/timezoneapi/ns-timezoneapi-dynamic_time_zone_information 388 | const TIME_DYNAMIC_ZONE_INFORMATION = extern struct { 389 | bias: windows.LONG, 390 | standard_name: [32]windows.WCHAR, 391 | standard_date: SYSTEMTIME, 392 | standard_bias: windows.LONG, 393 | daylight_name: [32]windows.WCHAR, 394 | daylight_date: SYSTEMTIME, 395 | daylight_bias: windows.LONG, 396 | timezone_key_name: [128]windows.WCHAR, 397 | dynamic_daylight_time_disabled: bool, 398 | }; 399 | 400 | pub extern "kernel32" fn GetLocalTime(ptr: *SYSTEMTIME) callconv(windows.WINAPI) void; 401 | pub extern "kernel32" fn GetDynamicTimeZoneInformation(ptr: *TIME_DYNAMIC_ZONE_INFORMATION) callconv(windows.WINAPI) windows.DWORD; 402 | 403 | fn transitionFromTz(comptime prefix: []const u8, tz: TIME_DYNAMIC_ZONE_INFORMATION, year: u16) Transition { 404 | const the_date = @field(tz, prefix ++ "_date"); 405 | const first_half: LocalDate = .{ 406 | .year = year, 407 | .month = @intCast(the_date.month), 408 | .day = @intCast(the_date.day), 409 | .local = true, 410 | }; 411 | const ts = first_half.toEpoch() + 412 | (the_date.hour) * 60 * 60 + 413 | the_date.minute * 60 + 414 | the_date.second; 415 | const bias = @field(tz, prefix ++ "_bias"); 416 | const offset = -(tz.bias - bias) * 60; 417 | return .{ 418 | .ts = ts - offset, 419 | .offset = offset, 420 | }; 421 | } 422 | 423 | // `initTransitions` MUST be called before any of these functions are used! 424 | 425 | pub fn utcOffset(instant: i64) i64 { 426 | std.debug.assert(tz_init); 427 | std.debug.assert(instant > instant_20220101); 428 | if (transitions) |trans| { 429 | for (trans, 0..) |t, i| { 430 | if (t.ts > instant) { 431 | return trans[i - 1].offset; 432 | } 433 | } 434 | } else { 435 | return 0; 436 | } 437 | unreachable; 438 | } 439 | 440 | pub inline fn utcToLocal(instant: i64) i64 { 441 | return instant + utcOffset(instant); 442 | } 443 | 444 | pub inline fn localToUtc(instant: i64) i64 { 445 | return instant - utcOffset(instant); 446 | } 447 | 448 | pub fn localAtStartOfDay(instant: i64) i64 { 449 | return epochAtStartOfDay(utcToLocal(instant)); 450 | } 451 | 452 | pub fn epochNowLocal() i64 { 453 | return utcToLocal(epochNow()); 454 | } 455 | 456 | pub fn getUserTimeZoneDb(allocator: Allocator) !std.Tz { 457 | var etc = try std.fs.openDirAbsolute("/etc", .{}); 458 | var tz_file = try etc.openFile("localtime", .{}); 459 | defer tz_file.close(); 460 | 461 | return std.Tz.parse(allocator, tz_file.reader()); 462 | } 463 | 464 | test "LocalDate prev next" { 465 | const S = struct { 466 | fn testPrevAndNext(d1: LocalDate, d2: LocalDate) !void { 467 | try expectEqual(d1, d2.prev()); 468 | try expectEqual(d1.next(), d2); 469 | } 470 | }; 471 | 472 | try S.testPrevAndNext(try LocalDate.init(2023, 2, 28), try LocalDate.init(2023, 3, 1)); 473 | 474 | // Leap year! 475 | try S.testPrevAndNext(try LocalDate.init(2024, 1, 31), try LocalDate.init(2024, 2, 1)); 476 | try S.testPrevAndNext(try LocalDate.init(2024, 2, 28), try LocalDate.init(2024, 2, 29)); 477 | try S.testPrevAndNext(try LocalDate.init(2024, 2, 29), try LocalDate.init(2024, 3, 1)); 478 | try S.testPrevAndNext(try LocalDate.init(2024, 3, 5), try LocalDate.init(2024, 3, 6)); 479 | try S.testPrevAndNext(try LocalDate.init(2024, 6, 5), try LocalDate.init(2024, 6, 6)); 480 | try S.testPrevAndNext(try LocalDate.init(2024, 10, 31), try LocalDate.init(2024, 11, 1)); 481 | try S.testPrevAndNext(try LocalDate.init(2023, 12, 31), try LocalDate.init(2024, 1, 1)); 482 | } 483 | -------------------------------------------------------------------------------- /src/help.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const Command = @import("main.zig").Command; 4 | 5 | pub const help_str = 6 | \\Usage: habu add 7 | \\ habu delete 8 | \\ habu display [] | 9 | \\ habu export 10 | \\ habu help [command] 11 | \\ habu import 12 | \\ habu info [] 13 | \\ habu link [] [...] 14 | \\ habu modify [ ...] 15 | \\ habu tag 16 | \\ habu unlink [] 17 | \\ habu version 18 | \\ 19 | \\Options: 20 | \\ --data-dir: absolute path to override where to read/write data files. 21 | \\ --show (all|active|stopped): which chains to include, see `modify` command for details on the `stopped` option 22 | \\ 23 | \\Chain: 24 | \\ A habit you want to track. The name comes from the "Don't break the chain" method of building 25 | \\ habits, where the goal is to check off your habit every day and thus build of a long "chain" 26 | \\ of checkmarks. 27 | \\ 28 | \\Link: 29 | \\ A checkmark of completion for a chain on a specific day. 30 | \\ 31 | \\Index: 32 | \\ Selects the chain for a command. 33 | \\ Found in the left-most number in parenthesis in the output of the `display` command. 34 | \\ 35 | \\Ranges: 36 | \\ Select multiple chains 37 | \\ 1-5: includes 1,2,3,4 and 5 38 | \\ 1,3: includes 1 and 3 39 | \\ Can be combined (duplicates are allowed): 1,3-5,2-4 40 | \\ 41 | \\Dates: 42 | \\ 43 | ++ dates_help ++ 44 | \\ 45 | \\ 46 | \\Tags: 47 | \\ Links can be tagged to make your tracking more precise. 48 | \\ For example, the chain 'Exercise' could have the tags 'gym', 'run' and 'swim'. 49 | \\ Tags are added to a chain with the `modify` command, and to a link with the `link` or `tag` commands. 50 | \\ You can add up to 4 tags per chain. 51 | \\ Tags are added to links as a comma-sperated list: 'tag1,tag2,tag3' 52 | \\ 53 | ; 54 | 55 | pub const dates_help = \\ t, today - Today 56 | \\ y, yesterday - Yesterday 57 | \\ yyyyMMdd - Date (20220101...) 58 | \\ - Weekday (mon/monday, tue/tuesday...) 59 | \\ - `n` days ago (1, 2, ... , 99) 60 | \\ st, nd, rd, th - `n`th day of the month (n <= today) / `n`th day of previous month (n >= today) 61 | ; 62 | 63 | pub fn commandHelp(command: Command) ?[]const u8 { 64 | return switch (command) { 65 | .add => \\ Adds a chain. 66 | \\ 67 | \\ Examples: 68 | \\ 69 | \\ `habu add Floss daily` 70 | \\ Adds a chain name "Floss" that needs to be linked every day to be unbroken. 71 | \\ 72 | \\ `habu add Exercise weekly 2` 73 | \\ Adds a chain name "Exercise" that needs to be linked at least 2 times a week to be unbroken. 74 | \\ 75 | , 76 | .delete => \\ Deletes a chain. The chain index is the left-most number in the `display` command output. 77 | \\ 78 | \\ Examples: 79 | \\ 80 | \\ `habu delete 2` 81 | \\ Deletes the chain with index 2. 82 | \\ 83 | , 84 | .@"export" => \\ Exports all chains and links to 'stdout' as JSON. The data can be imported back to the Habu format with the `import` command. 85 | \\ 86 | , 87 | .import => \\ Reads data created by the `export` command from 'stdin' and creates new files in the data folder with a `.imported` suffix. 88 | \\ Since the imported files are created with a suffix, importing data never overwrites the primary data files, but it will overwrite 89 | \\ any other imported data. 90 | \\ 91 | \\ Examples: 92 | \\ 93 | \\ Export your data to `data.json` 94 | \\ `habu export > data.json` 95 | \\ 96 | \\ Import `data.json` to `chains.bin.imported` and `links.bin.imported` 97 | \\ `habu import < data.json` 98 | \\ 99 | \\ Remove the suffix from the imported files so Habu will load them 100 | \\ `cd .habu && mv chains.bin.imported chains.bin` && mv links.bin.imported links.bin` 101 | \\ 102 | , 103 | .modify => \\ Modifies an attribute on an existing chain. 104 | \\ 105 | \\ Attributes: 106 | \\ 107 | \\ - name (max length 128) 108 | \\ - type (daily/weekly) 109 | \\ - min_days (1-7) 110 | \\ - stopped ( | false) 111 | \\ 112 | \\ Examples: 113 | \\ 114 | \\ Change the name of chain 2 to 'Another name' 115 | \\ `habu modify 2 name 'Another name'` 116 | \\ 117 | \\ Change the type of chain 1 from weekly to daily 118 | \\ `habu modify 1 type daily` 119 | \\ 120 | \\ Change the min_days of chain 3 to 5 121 | \\ `habu modify 3 min_days 5` 122 | \\ 123 | \\ Change the visibility of the chain (stopped chains are hidden by default) 124 | \\ `habu modify 1 stopped 20231101` 125 | \\ 126 | , 127 | .link => \\ Marks a chain as completed on the specified date. 128 | \\ 129 | \\ See `habu help dates` for more information about date formats, and `habu help ranges` for the chain selection syntax. 130 | \\ 131 | \\ Examples: 132 | \\ 133 | \\ Link chain 1 today 134 | \\ `habu link 1` 135 | \\ 136 | \\ Link chain 2 yesterday 137 | \\ `habu link 2 y` or `habu link 2 yesterday` 138 | \\ 139 | \\ Link chain 1, 2, 3 and 5 on the 23rd of June 140 | \\ `habu link 1-3,5 20230623` 141 | \\ 142 | \\ Link chain 1 today with the tags 'exercise,run' 143 | \\ `habu link 1 t exercise,run 144 | \\ 145 | , 146 | .unlink => \\ Removes the completion of a chain on the specified date. 147 | \\ 148 | \\ See `habu help dates` for more information about date formats, and `habu help ranges` for the chain selection syntax. 149 | \\ 150 | \\ Examples: 151 | \\ 152 | \\ Unlink chain 1 today 153 | \\ `habu unlink 1` 154 | \\ 155 | \\ Unlink chain 2 yesterday 156 | \\ `habu unlink 2 y` or `habu link 2 yesterday` 157 | \\ 158 | \\ Unlink chain 1, 2, 3 and 5 on the 23rd of June 159 | \\ `habu unlink 1-3,5 20230623` 160 | \\ 161 | , 162 | .info => \\ Shows detailed information about a chain or a link. 163 | \\ 164 | \\ Examples: 165 | \\ 166 | \\ Show info for chain 2 167 | \\ `habu info 2` 168 | \\ 169 | \\ Show info for the link on 20230404 on chain 1 170 | \\ `habu info 1 20230404` 171 | \\ 172 | , 173 | else => null, 174 | }; 175 | } 176 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const build_options = @import("build_options"); 3 | const builtin = @import("builtin"); 4 | const color = @import("color.zig"); 5 | const date = @import("date.zig"); 6 | const help = @import("help.zig"); 7 | const tui = @import("tui.zig"); 8 | 9 | const windows = std.os.windows; 10 | 11 | const Allocator = std.mem.Allocator; 12 | const LocalDate = date.LocalDate; 13 | 14 | fn lowerBound( 15 | comptime T: type, 16 | key: anytype, 17 | items: []const T, 18 | context: anytype, 19 | comptime lessThan: *const fn (context: @TypeOf(context), key: @TypeOf(key), lhs: T) bool, 20 | ) usize { 21 | var left: usize = 0; 22 | var right: usize = items.len; 23 | var mid: usize = undefined; 24 | while (left < right) { 25 | mid = left + (right - left) / 2; 26 | if (lessThan(context, key, items[mid])) { 27 | right = mid; 28 | } else { 29 | left = mid + 1; 30 | } 31 | } 32 | return left; 33 | } 34 | 35 | fn panic() noreturn { 36 | @panic("fatal error"); 37 | } 38 | 39 | pub const Kind = enum(u8) { 40 | daily, 41 | weekly, 42 | }; 43 | 44 | pub const ChainMeta = extern struct { 45 | id_counter: u16, 46 | len: u16, 47 | _padding: u32 = 0, 48 | }; 49 | 50 | pub const tag_name_max_len = 15; 51 | pub const Tag = extern struct { 52 | header: packed struct { 53 | id: u3, 54 | len: u5, 55 | }, 56 | name: [tag_name_max_len]u8, 57 | 58 | const Self = @This(); 59 | 60 | pub fn getName(self: *const Self) []const u8 { 61 | return self.name[0..self.header.len]; 62 | } 63 | }; 64 | 65 | pub const ACTIVE: i64 = 0; 66 | pub const chain_name_max_len = 128; 67 | pub const max_tags = 4; 68 | pub const Chain = extern struct { 69 | name: [chain_name_max_len]u8, 70 | created: i64, 71 | id: u16, 72 | name_len: u16, 73 | kind: Kind, 74 | color: color.Rgb, 75 | min_days: u8, // `0` means N/A 76 | n_tags: u8, 77 | tags: [max_tags]Tag, 78 | stopped: i64, 79 | _padding: [30]u8 = std.mem.zeroes([30]u8), 80 | 81 | const Self = @This(); 82 | 83 | pub fn isActive(self: *const Self) bool { 84 | return self.stopped == ACTIVE; 85 | } 86 | 87 | pub fn getTags(self: *const Self) []const Tag { 88 | return self.tags[0..self.n_tags]; 89 | } 90 | 91 | pub fn maxTagsId(self: *const Self) u8 { 92 | if (self.n_tags == 0) 93 | return 0; 94 | 95 | var i: usize = 0; 96 | var tag_id: u8 = 0; 97 | while (i < self.n_tags) : (i += 1) { 98 | tag_id = @max(tag_id, self.tags[i].header.id); 99 | } 100 | return tag_id; 101 | } 102 | 103 | // TODO: handle more than the first 3 tags 104 | pub fn tagColor(self: *const Self, tags: u8) color.Rgb { 105 | // TODO: distinct color for tag #4 106 | if (@popCount(tags) > 3 or ((tags >> 3) & 1) == 1) 107 | return color.white; 108 | 109 | const f = 28; 110 | const m: u8 = f *% (5 * (tags & 0x01) + 3 * ((tags >> 1) & 0x01) + ((tags >> 2) & 0x01)); 111 | return .{ 112 | .r = self.color.r +% m, 113 | .g = self.color.g +% m, 114 | .b = self.color.b +% m, 115 | }; 116 | } 117 | 118 | pub fn tagsFromNames(self: *const Self, names_it: *std.mem.SplitIterator(u8, .sequence)) u8 { 119 | var link_tags: u8 = 0; 120 | const tags = self.tags[0..self.n_tags]; 121 | while (names_it.next()) |name| { 122 | for (tags) |tag| { 123 | if (std.mem.eql(u8, name, tag.getName())) { 124 | link_tags ^= (@as(u5, 1) << (tag.header.id - 1)); 125 | break; 126 | } 127 | } else { 128 | var buf = std.mem.zeroes([max_tags * tag_name_max_len + 8]u8); // +8 for ", " 129 | var fba = std.io.fixedBufferStream(&buf); 130 | var w = fba.writer(); 131 | for (tags, 0..) |tag, i| { 132 | w.writeAll(tag.getName()) catch unreachable; 133 | if (i < tags.len - 1) 134 | w.writeAll(", ") catch unreachable; 135 | } 136 | printAndExit("Chain '{d}' has no tag '{s}', available tags: [{s}]\n", .{ self.id, name, fba.getWritten() }); 137 | } 138 | } 139 | return link_tags; 140 | } 141 | }; 142 | 143 | comptime { 144 | std.debug.assert(@sizeOf(ChainMeta) == 8); 145 | std.debug.assert(@sizeOf(Chain) == 256); 146 | } 147 | 148 | pub const ChainDb = struct { 149 | allocator: Allocator, 150 | file: std.fs.File, 151 | show: Show, 152 | meta: ChainMeta = undefined, 153 | chains: std.ArrayList(Chain) = undefined, 154 | filtered_chains: std.ArrayList(*Chain) = undefined, 155 | materialized: bool = false, 156 | 157 | const Self = @This(); 158 | 159 | pub fn materialize(self: *Self) !void { 160 | std.debug.assert(!self.materialized); 161 | try self.file.seekTo(0); 162 | var r = self.file.reader(); 163 | const stat = try self.file.stat(); 164 | // Pre-allocate space for one additional chain 165 | var bytes = try self.allocator.allocWithOptions(u8, stat.size + @sizeOf(Chain), @alignOf(Chain), null); 166 | _ = try r.readAll(bytes); 167 | 168 | self.meta = @bitCast(bytes[0..@sizeOf(ChainMeta)].*); 169 | const chain_bytes: [*]Chain = @ptrCast(@alignCast(bytes[@sizeOf(ChainMeta)..])); 170 | self.chains = std.ArrayList(Chain).fromOwnedSlice(self.allocator, chain_bytes[0..self.meta.len]); 171 | self.chains.capacity = self.meta.len + 1; 172 | self.filtered_chains = try std.ArrayList(*Chain).initCapacity(self.allocator, self.meta.len + 1); 173 | self.refilter(); 174 | 175 | self.materialized = true; 176 | } 177 | 178 | fn refilter(self: *Self) void { 179 | self.filtered_chains.clearRetainingCapacity(); 180 | for (self.chains.items) |*chain| { 181 | const include = (self.show == .all) or 182 | (self.show == .stopped and !chain.isActive()) or 183 | (self.show == .active and chain.isActive()); 184 | if (include) 185 | self.filtered_chains.appendAssumeCapacity(chain); 186 | } 187 | } 188 | 189 | fn persist(self: *Self) !void { 190 | std.debug.assert(self.materialized); 191 | 192 | try self.file.seekTo(0); 193 | var w = self.file.writer(); 194 | try w.writeStruct(self.meta); 195 | try w.writeAll(std.mem.sliceAsBytes(self.chains.items)); 196 | try self.file.setEndPos(@sizeOf(ChainMeta) + self.chains.items.len * @sizeOf(Chain)); 197 | } 198 | 199 | fn getByIndex(self: *Self, index: u16) *Chain { 200 | std.debug.assert(self.materialized); 201 | return self.filtered_chains.items[index - 1]; 202 | } 203 | 204 | fn indexToId(self: *const Self, index: usize)?u16 { 205 | std.debug.assert(self.materialized); 206 | if (index == 0 or index > self.filtered_chains.items.len) return null; 207 | return self.filtered_chains.items[index - 1].id; 208 | } 209 | 210 | fn getChains(self: Self) []*Chain { 211 | std.debug.assert(self.materialized); 212 | return self.filtered_chains.items; 213 | } 214 | 215 | fn add(self: *Self, chain: *Chain) !void { 216 | std.debug.assert(self.materialized); 217 | self.meta.len += 1; 218 | self.meta.id_counter += 1; 219 | self.chains.appendAssumeCapacity(chain.*); 220 | self.filtered_chains.appendAssumeCapacity(&self.chains.items[self.chains.items.len - 1]); 221 | } 222 | 223 | fn delete(self: *Self, index: u16) void { 224 | std.debug.assert(self.materialized); 225 | const removed = self.filtered_chains.orderedRemove(index - 1); 226 | for (self.chains.items, 0..) |*chain, i| { 227 | if (chain.id == removed.id) { 228 | _ = self.chains.orderedRemove(i); 229 | break; 230 | } 231 | } else unreachable; 232 | self.meta.len -= 1; 233 | self.refilter(); 234 | } 235 | }; 236 | 237 | pub const LinkMeta = extern struct { 238 | _padding1: u16 = 0, 239 | len: u16, 240 | _padding2: u32 = 0, 241 | }; 242 | 243 | pub const Link = extern struct { 244 | _padding1: u16 = 0, 245 | chain_id: u16, 246 | tags: u8 = 0, // bitmap 247 | _padding2: u8 = 0, 248 | _padding3: u16 = 0, 249 | timestamp: i64, 250 | 251 | const Self = @This(); 252 | 253 | pub fn localAtStartOfDay(self: *const Self) i64 { 254 | return date.localAtStartOfDay(self.timestamp); 255 | } 256 | 257 | pub fn local(self: *const Self) i64 { 258 | return date.utcToLocal(self.timestamp); 259 | } 260 | 261 | pub fn toLocalDate(self: *const Self) LocalDate { 262 | return LocalDate.epochToLocal(self.timestamp); 263 | } 264 | }; 265 | 266 | comptime { 267 | std.debug.assert(@sizeOf(LinkMeta) == 8); 268 | std.debug.assert(@sizeOf(Link) == 16); 269 | } 270 | 271 | pub const LinkDb = struct { 272 | allocator: Allocator, 273 | file: std.fs.File, 274 | meta: LinkMeta = undefined, 275 | links: std.ArrayList(Link) = undefined, 276 | materialized: bool = false, 277 | first_write_index: usize = NO_WRITE, 278 | 279 | const NO_WRITE = std.math.maxInt(usize); 280 | const Self = @This(); 281 | 282 | pub fn materialize(self: *Self, n_chains: usize) !void { 283 | std.debug.assert(!self.materialized); 284 | 285 | const stat = try self.file.stat(); 286 | var bytes = try self.allocator.allocWithOptions(u8, stat.size + n_chains * @sizeOf(Link), @alignOf(Link), null); 287 | 288 | try self.file.seekTo(0); 289 | var r = self.file.reader(); 290 | _ = try r.readAll(bytes); 291 | 292 | self.meta = @bitCast(bytes[0..@sizeOf(LinkMeta)].*); 293 | var link_bytes: [*]Link = @ptrCast(@alignCast(bytes[@sizeOf(LinkMeta)..])); 294 | const n_links = @divExact(stat.size - @sizeOf(LinkMeta), @sizeOf(Link)); 295 | self.links = std.ArrayList(Link).fromOwnedSlice(self.allocator, link_bytes[0..n_links]); 296 | self.links.capacity += n_chains; 297 | 298 | self.materialized = true; 299 | } 300 | 301 | fn getLinks(self: *Self) []Link { 302 | std.debug.assert(self.materialized); 303 | return self.links.items; 304 | } 305 | 306 | fn getAndSortLinks(self: *Self, local_date: ?LocalDate) []Link { 307 | std.debug.assert(self.materialized); 308 | const S = struct { 309 | fn first(_: void, ts: i64, lhs: Link) bool { 310 | return ts <= lhs.timestamp; 311 | } 312 | }; 313 | const i = if (local_date) |d| 314 | lowerBound(Link, d.prev().toEpoch(), self.links.items, {}, S.first) 315 | else 316 | 0; 317 | var links_in_range = self.links.items[i..]; 318 | std.sort.pdq(Link, links_in_range, {}, orderLinks); 319 | return links_in_range; 320 | } 321 | 322 | fn getLinksForChain(self: *Self, chain_id: u16, local_date: ?LocalDate) []Link { 323 | var links = self.getAndSortLinks(local_date); 324 | const chain_start = LinkDb.chainStartIndex(links, chain_id) orelse return &.{}; 325 | const chain_end = LinkDb.chainEndIndex(links, chain_id).?; // if there's a start there's an end 326 | return links[chain_start .. chain_end]; 327 | } 328 | 329 | fn getInsertIndex(links: []Link, chain_id: u16, timestamp: i64) struct { index: usize, occupied: bool } { 330 | const S = struct { 331 | fn first(_: void, ts: i64, lhs: Link) bool { 332 | return ts <= lhs.localAtStartOfDay(); 333 | } 334 | }; 335 | var i = lowerBound(Link, timestamp, links, {}, S.first); 336 | const day = date.localAtStartOfDay(timestamp); 337 | std.log.debug("getInsertIndex chain id {d} timestamp {d} -> i {d} len {d}", .{chain_id, timestamp, i, links.len}); 338 | 339 | if (i == links.len) { 340 | while (i > 0) { 341 | i -= 1; 342 | const other = links[i]; 343 | if (other.localAtStartOfDay() != day) { 344 | break; 345 | } 346 | } 347 | } 348 | 349 | std.log.debug(" i {d} day {d} links len {d}", .{i, day, links.len}); 350 | while (i < links.len) : (i += 1) { 351 | const link = links[i]; 352 | const link_day = link.localAtStartOfDay(); 353 | std.log.debug(" loop -> i {d} day {d} link day {d}", .{i, day, link_day}); 354 | if (link_day > day) return .{ .index = i, .occupied = false }; 355 | if (link.chain_id == chain_id and link_day == day) { 356 | return .{ .index = i, .occupied = true }; 357 | } 358 | } 359 | return .{ .index = i, .occupied = false }; 360 | } 361 | 362 | fn add(self: *Self, link: Link) !void { 363 | std.debug.assert(self.materialized); 364 | 365 | const result = LinkDb.getInsertIndex(self.links.items, link.chain_id, link.timestamp); 366 | if (result.occupied) { 367 | var w = std.io.getStdOut().writer(); 368 | try w.print("Link already exists on date {s}, skipping\n", .{link.toLocalDate().asString()}); 369 | return; 370 | } 371 | 372 | self.meta.len += 1; 373 | const i = result.index; 374 | self.links.insertAssumeCapacity(i, link); 375 | if (i < self.first_write_index) 376 | self.first_write_index = i; 377 | } 378 | 379 | fn remove(self: *Self, chain_id: u16, day: i64) bool { 380 | std.debug.assert(self.materialized); 381 | const result = LinkDb.getInsertIndex(self.links.items, chain_id, day); 382 | if (!result.occupied) return false; 383 | 384 | self.meta.len -= 1; 385 | const i = result.index; 386 | _ = self.links.orderedRemove(i); 387 | if (i < self.first_write_index) 388 | self.first_write_index = i; 389 | return true; 390 | } 391 | 392 | fn persist(self: *Self) !void { 393 | std.debug.assert(self.materialized); 394 | try self.verify(); 395 | 396 | if (self.first_write_index == NO_WRITE) return; 397 | var w = self.file.writer(); 398 | 399 | try self.file.seekTo(0); 400 | try w.writeStruct(self.meta); 401 | 402 | const write_start = @sizeOf(LinkMeta) + self.first_write_index * @sizeOf(Link); 403 | std.log.debug("LinkDb.persist -> items len {d} meta len {d} fwi {d} write start {d}", .{self.links.items.len, self.meta.len, self.first_write_index, write_start}); 404 | try self.file.seekTo(write_start); 405 | const link_bytes = std.mem.sliceAsBytes(self.links.items[self.first_write_index..]); 406 | try w.writeAll(link_bytes); 407 | try self.file.setEndPos(@sizeOf(LinkMeta) + self.links.items.len * @sizeOf(Link)); 408 | } 409 | 410 | fn chainStartIndex(links: []Link, chain_id: u16) ?usize { 411 | const S = struct { 412 | fn first(_: void, cid: u16, lhs: Link) bool { 413 | return cid <= lhs.chain_id; 414 | } 415 | }; 416 | var index = lowerBound(Link, chain_id, links, {}, S.first); 417 | if (index == links.len) return null; 418 | return if (links[index].chain_id == chain_id) 419 | index 420 | else 421 | null; 422 | } 423 | 424 | fn chainEndIndex(links: []Link, chain_id: u16) ?usize { 425 | const S = struct { 426 | fn last(_: void, cid: u16, lhs: Link) bool { 427 | return cid < lhs.chain_id; 428 | } 429 | }; 430 | const index = lowerBound(Link, chain_id, links, {}, S.last); 431 | return if (links[index - 1].chain_id == chain_id) 432 | index 433 | else 434 | null; 435 | } 436 | 437 | fn verify(self: *Self) !void { 438 | if (builtin.mode == .ReleaseFast or builtin.mode == .ReleaseSmall) return; 439 | 440 | std.debug.assert(self.materialized); 441 | const ChainIdAndDate = struct { 442 | chain_id: u16, 443 | timestamp: i64, 444 | }; 445 | var seen = std.AutoHashMap(ChainIdAndDate, void).init(self.allocator); 446 | defer seen.deinit(); 447 | var n_links: usize = 0; 448 | var now = date.epochNowLocal(); 449 | for (self.links.items, 0..) |link, i| { 450 | const chain_id_and_date = .{ 451 | .chain_id = link.chain_id, 452 | .timestamp = link.localAtStartOfDay() 453 | }; 454 | if (link.local() > now) { 455 | std.log.info("LINK DB INCONSISTENT! link timestamp in future! link index {d} {}", .{i, chain_id_and_date}); 456 | panic(); 457 | } 458 | if (seen.contains(chain_id_and_date)) { 459 | std.log.info("LINK DB INCONSISTENT! Duplicate link i {d} {}", .{i, chain_id_and_date}); 460 | panic(); 461 | } 462 | try seen.put(chain_id_and_date, {}); 463 | n_links += 1; 464 | } 465 | if (n_links != self.meta.len) { 466 | std.log.info("LINK DB INCONSISTENT! meta len and actual link count differ, meta len {d}, actual len {d}", .{self.meta.len, n_links}); 467 | panic(); 468 | } 469 | } 470 | }; 471 | 472 | fn orderLinks(ctx: void, a: Link, b: Link) bool { 473 | _ = ctx; 474 | if (a.chain_id == b.chain_id) 475 | return a.timestamp < b.timestamp; 476 | return a.chain_id < b.chain_id; 477 | } 478 | 479 | fn orderLinksTimestamp(ctx: void, a: Link, b: Link) bool { 480 | _ = ctx; 481 | return a.timestamp < b.timestamp; 482 | } 483 | 484 | pub const Stats = struct { 485 | longest_gap: usize, 486 | longest_streak: usize, 487 | times_broken: usize, 488 | fulfillment: [32]u8, 489 | }; 490 | 491 | fn computeStats(allocator: Allocator, chain: *const Chain, links: []const Link) !Stats { 492 | if (links.len == 0) { 493 | var stats = Stats{ 494 | .longest_streak = 0, 495 | .longest_gap = 0, 496 | .times_broken = 0, 497 | .fulfillment = std.mem.zeroes([32]u8), 498 | }; 499 | _ = std.fmt.bufPrint(&stats.fulfillment, "0/0 (100%)", .{}) catch unreachable; 500 | return stats; 501 | } 502 | switch (chain.kind) { 503 | .daily => { 504 | var streak: usize = 0; 505 | var max_streak: usize = 0; 506 | var max_gap: usize = 0; 507 | var times_broken: usize = 0; 508 | var i: usize = 1; 509 | while (i < links.len) : (i += 1) { 510 | const prev = links[i - 1].local(); 511 | const curr = links[i].local(); 512 | const days = date.daysBetween(prev, curr); 513 | if (days > max_gap) 514 | max_gap = days; 515 | if (days == 0) { 516 | streak += 1; 517 | } else { 518 | if (streak > 0) 519 | times_broken += 1; 520 | max_streak = @max(streak, max_streak); 521 | streak = 0; 522 | } 523 | } 524 | 525 | const first_link_start = links[0].localAtStartOfDay(); 526 | const end = if (chain.isActive()) 527 | date.epochAtStartOfDay(date.epochNowLocal()) 528 | else 529 | chain.stopped; 530 | // +2 because we include both the start date and the end date in the count 531 | const n_days = date.daysBetween(first_link_start, end) + 2; 532 | const percentage = @as(f32, @floatFromInt(links.len)) / @as(f32, @floatFromInt(n_days)) * 100; 533 | var fulfillment = std.mem.zeroes([32]u8); 534 | _ = std.fmt.bufPrint(&fulfillment, "{d}/{d} ({d:.2}%)", .{ links.len, n_days, percentage }) catch unreachable; 535 | 536 | const last_link_at_start_of_day = links[links.len - 1].localAtStartOfDay(); 537 | const days = date.daysBetween(last_link_at_start_of_day, end); 538 | if (days > max_gap) 539 | max_gap = days + 1; // +1 since there is no link on `now` 540 | if (days > 0) 541 | times_broken += 1; 542 | 543 | return .{ 544 | // +1 because we're counting the number of links, not the days 545 | // https://en.wikipedia.org/wiki/Off-by-one_error?useskin=vector#Fencepost_error 546 | // ██━━██━━██ -> 2 links, 3 days 547 | .longest_streak = @max(streak, max_streak) + 1, 548 | .longest_gap = max_gap, 549 | .times_broken = times_broken, 550 | .fulfillment = fulfillment, 551 | }; 552 | }, 553 | .weekly => { 554 | const Weeks = [date.max_weeks_per_year + 1]u8; 555 | const YearWeeks = struct { 556 | weeks: Weeks = std.mem.zeroes(Weeks), 557 | min_week: u16 = 53, 558 | max_week: u16 = 0, 559 | }; 560 | var year_weeks = std.AutoArrayHashMap(u16, YearWeeks).init(allocator); 561 | for (links) |link| { 562 | const local = link.local(); 563 | var week: u16 = @intCast(date.getWeekNumberFromEpoch(local)); 564 | var year = LocalDate.fromEpoch(local).year; 565 | if (week == 0) { 566 | year -= 1; 567 | week = 52; 568 | } 569 | var gop = try year_weeks.getOrPut(year); 570 | if (!gop.found_existing) 571 | gop.value_ptr.* = .{}; 572 | var year_stats = gop.value_ptr; 573 | year_stats.weeks[week] += 1; 574 | year_stats.min_week = @min(week, year_stats.min_week); 575 | year_stats.max_week = @max(week, year_stats.max_week); 576 | } 577 | var gap: usize = 0; 578 | var max_gap: usize = 0; 579 | var streak: usize = 0; 580 | var max_streak: usize = 0; 581 | var times_broken: usize = 0; 582 | var weeks_completed: usize = 0; 583 | var n_weeks: usize = 0; 584 | 585 | for (year_weeks.values(), 0..) |stats, i| { 586 | const weeks: []const u8 = if (i == 0) 587 | stats.weeks[stats.min_week..] 588 | else if (i == year_weeks.count() - 1) 589 | stats.weeks[0..stats.max_week] 590 | else 591 | &stats.weeks; 592 | for (weeks) |n| { 593 | if (n < chain.min_days) { 594 | gap += 1; 595 | if (streak > 0) 596 | times_broken += 1; 597 | max_streak = @max(streak, max_streak); 598 | streak = 0; 599 | } else { 600 | max_gap = @max(gap, max_gap); 601 | gap = 0; 602 | streak += 1; 603 | weeks_completed += 1; 604 | } 605 | n_weeks += 1; 606 | } 607 | } 608 | 609 | const percentage = @as(f32, @floatFromInt(weeks_completed)) / @as(f32, @floatFromInt(n_weeks)) * 100; 610 | var fulfillment = std.mem.zeroes([32]u8); 611 | _ = std.fmt.bufPrint(&fulfillment, "{d}/{d} ({d:.2}%)", .{ weeks_completed, n_weeks, percentage }) catch unreachable; 612 | 613 | return .{ 614 | .longest_gap = @max(gap, max_gap), 615 | .longest_streak = @max(streak, max_streak), 616 | .times_broken = times_broken, 617 | .fulfillment = fulfillment, 618 | }; 619 | }, 620 | } 621 | } 622 | 623 | pub const Command = enum { 624 | add, 625 | delete, 626 | display, 627 | @"export", 628 | help, 629 | import, 630 | info, // TODO: better name 631 | link, 632 | modify, 633 | tag, 634 | unlink, 635 | version, 636 | }; 637 | 638 | var scratch = std.mem.zeroes([256]u8); 639 | var err_buf = std.mem.zeroes([13]u8); 640 | 641 | fn trunc(str: []const u8) []const u8 { 642 | if (str.len <= err_buf.len) { 643 | const n = @min(str.len, err_buf.len); 644 | std.mem.copy(u8, &err_buf, str[0..n]); 645 | return err_buf[0..n]; 646 | } else { 647 | std.mem.copy(u8, &err_buf, str[0..10]); 648 | std.mem.copy(u8, err_buf[10..err_buf.len], "..."); 649 | return err_buf[0..]; 650 | } 651 | } 652 | 653 | pub fn scratchPrint(comptime str: []const u8, args: anytype) []const u8 { 654 | return std.fmt.bufPrint(&scratch, str, args) catch panic(); 655 | } 656 | 657 | 658 | fn formatEnumValuesForPrint(comptime E: type, allocator: Allocator) ![]const u8 { 659 | var strs: [@typeInfo(E).Enum.fields.len][]const u8 = undefined; 660 | inline for (comptime std.enums.values(E), 0..) |e, i| { 661 | strs[i] = @tagName(e); 662 | } 663 | const values = try std.mem.join(allocator, ", ", &strs); 664 | return scratchPrint("[{s}]", .{values}); 665 | } 666 | 667 | // TODO: can the error message by generated lazily somehow? 668 | fn parseIntOrExit(comptime T: type, str: []const u8, err_msg: []const u8) T { 669 | return std.fmt.parseInt(T, str, 10) catch |err| { 670 | var w = std.io.getStdOut().writer(); 671 | w.writeAll(err_msg) catch panic(); 672 | w.writeAll(", ") catch panic(); 673 | w.writeAll(switch (err) { 674 | error.InvalidCharacter => "expected integer", 675 | error.Overflow => "integer too large", 676 | }) catch panic(); 677 | w.writeByte('\n') catch panic(); 678 | std.process.exit(0); 679 | }; 680 | } 681 | 682 | fn printHelpAndExit(message: ?[]const u8) noreturn { 683 | var stdout = std.io.getStdOut(); 684 | var w = stdout.writer(); 685 | if (message) |m| { 686 | w.writeAll(m) catch panic(); 687 | } 688 | w.writeAll(help.help_str) catch panic(); 689 | std.process.exit(0); 690 | } 691 | 692 | fn parseCommandOrExit(str: []const u8) Command { 693 | return std.meta.stringToEnum(Command, str) orelse { 694 | const msg = scratchPrint("Invalid command '{s}'\n", .{trunc(str)}); 695 | printHelpAndExit(msg); 696 | }; 697 | } 698 | 699 | fn checkNumberOfArgs(allocator: Allocator, args: []const []const u8, max: usize) !void { 700 | if (args.len == 0) return; 701 | 702 | // The first element of `args` is the command which doesn't count 703 | const len = args.len - 1; 704 | if (len > max) { 705 | const extra_args = try std.mem.join(allocator, ", ", args[max + 1..]); 706 | printAndExit("Expected at most {} arguments, got {}, extra: [{s}]\n", .{max, len, extra_args}); 707 | } 708 | } 709 | 710 | fn optionalArg(args: []const []const u8, index: usize) ?[]const u8 { 711 | return if (args.len > index) args[index] else null; 712 | } 713 | 714 | fn expectArg(args: []const []const u8, index: usize, comptime name: []const u8) []const u8 { 715 | if (optionalArg(args, index)) |a| { 716 | return a; 717 | } else { 718 | const msg = scratchPrint("Expected '{s}' argument\n", .{name}); 719 | printHelpAndExit(msg); 720 | } 721 | } 722 | 723 | fn printAndExit(comptime fmt: []const u8, args: anytype) noreturn { 724 | var w = std.io.getStdOut().writer(); 725 | w.print(fmt, args) catch panic(); 726 | std.process.exit(0); 727 | } 728 | 729 | const IndexAndId = struct { index: u16, id: u16 }; 730 | 731 | fn parseAndValidateChainIndex(chain_db: *ChainDb, str: []const u8) !IndexAndId { 732 | const index = parseChainIndex(str); 733 | const cid = validateChainIndex(chain_db, index); 734 | return .{ .id = cid, .index = index }; 735 | } 736 | 737 | fn parseChainIndex(str: []const u8) u16 { 738 | const err_msg = scratchPrint("Invalid chain id '{s}'", .{trunc(str)}); 739 | const index = parseIntOrExit(u16, str, err_msg); 740 | if (index == 0) 741 | printAndExit("Invalid chain index '{d}' (chain index must be > 0)\n", .{index}); 742 | return index; 743 | } 744 | 745 | fn validateChainIndex(chain_db: *ChainDb, index: u16) u16 { 746 | return chain_db.indexToId(index) orelse printAndExit("No chain found with index '{d}'\n", .{index}); 747 | } 748 | 749 | fn validateNameLen(name: []const u8) void { 750 | if (name.len > chain_name_max_len) { 751 | printAndExit("Invalid name '{s}...', name must be < {d} characters\n", .{ name[0..10], chain_name_max_len }); 752 | } 753 | } 754 | 755 | fn validateTagNameLen(name: []const u8) void { 756 | if (name.len > tag_name_max_len) { 757 | printAndExit("Invalid tag name '{s}...', name must be < {d} characters\n", .{ name[0..10], tag_name_max_len }); 758 | } 759 | } 760 | 761 | fn parseMinDaysOrExit(str: []const u8) u8 { 762 | const err_msg = scratchPrint("Invalid min_days '{s}'", .{trunc(str)}); 763 | const min_days = parseIntOrExit(u8, str, err_msg); 764 | if (min_days <= 0 or min_days > 7) { 765 | printAndExit("Invalid min_days '{d}', min: 0, max: 7\n", .{min_days}); 766 | } 767 | return min_days; 768 | } 769 | 770 | pub fn parseLocalDateOrExit(str: []const u8, label: []const u8) LocalDate { 771 | const parsed = parseLocalDateOrExitInternal(str, label); 772 | const now = date.epochNow(); 773 | if (parsed.toEpoch() > now) { 774 | const now_local_date = LocalDate.epochToLocal(now); 775 | printAndExit("Invalid {s} date '{s}', dates must be less than or equal to today ({s})\n", .{label, parsed.asString(), now_local_date.asString()}); 776 | } 777 | return parsed; 778 | } 779 | 780 | fn parseLocalDateOrExitInternal(str: []const u8, label: []const u8) LocalDate { 781 | const S = struct { 782 | fn printDateHelpAndExit(str_: []const u8, label_: []const u8, msg: []const u8) noreturn { 783 | printAndExit("Invalid {s} date '{s}', {s}\n\nValid date formats:\n{s}\n", .{label_, str_, msg, help.dates_help}); 784 | } 785 | }; 786 | 787 | if (std.mem.eql(u8, str, "y") or std.mem.eql(u8, str, "yesterday")) { 788 | return LocalDate.fromEpoch(date.epochNowLocal() - date.secs_per_day); 789 | } 790 | 791 | if (std.mem.eql(u8, str, "t") or std.mem.eql(u8, str, "today")) { 792 | return LocalDate.fromEpoch(date.epochNowLocal()); 793 | } 794 | 795 | // Weekdays: mon/monday, tue/tuesday etc. 796 | if (str.len >= 3) { 797 | if (std.meta.stringToEnum(date.Weekday, str[0..3])) |target_day| { 798 | 799 | const name = date.weekday_names[@intFromEnum(target_day)]; 800 | if (str.len > name.len or !std.mem.eql(u8, str, name[0..str.len])) 801 | S.printDateHelpAndExit(str, label, "not a weekday"); 802 | 803 | const now = date.epochNowLocal(); 804 | const today = date.getWeekdayFromEpoch(now); 805 | const diff = @as(i64, @intCast(@intFromEnum(today))) - @as(i64, @intCast(@intFromEnum(target_day))); 806 | const n_days = (diff + @as(i64, if (diff >= 0) 0 else 7)); 807 | const target_day_epoch = now - n_days * date.secs_per_day; 808 | return LocalDate.fromEpoch(target_day_epoch); 809 | } 810 | } 811 | 812 | // Ordinals: 1st, 2nd, 3rd... 813 | if ( 814 | std.mem.endsWith(u8, str, "st") or 815 | std.mem.endsWith(u8, str, "nd") or 816 | std.mem.endsWith(u8, str, "th") or 817 | std.mem.endsWith(u8, str, "rd") 818 | ) { 819 | var today = LocalDate.fromEpoch(date.epochNowLocal()); 820 | 821 | // For errors, TODO make lazy 822 | const last_month = today.oneMonthAgo(); 823 | const last_month_name = date.monthName(last_month.month); 824 | const last_month_n_days = date.getDaysInMonth(last_month.year, last_month.month); 825 | const out_of_range_msg = scratchPrint("out of range for {s} which has {d} days", .{last_month_name, last_month_n_days}); 826 | 827 | const day_number_signed = std.fmt.parseInt(i64, str[0..str.len - 2], 10) catch |err| switch (err) { 828 | error.Overflow => S.printDateHelpAndExit(str, label, out_of_range_msg), 829 | else => S.printDateHelpAndExit(str, label, "does not match any format"), 830 | }; 831 | if (day_number_signed < 1) 832 | S.printDateHelpAndExit(str, label, "does not match any format"); 833 | if (day_number_signed > 31) 834 | S.printDateHelpAndExit(str, label, out_of_range_msg); 835 | const day_number: u8 = @intCast(day_number_signed); 836 | return if (day_number <= today.day) 837 | LocalDate.init(today.year, today.month, day_number) catch unreachable 838 | else 839 | today.prevMonthAtDay(day_number) catch |err| switch (err) { 840 | error.DayOutOfRange => S.printDateHelpAndExit(str, label, out_of_range_msg), 841 | else => unreachable, 842 | }; 843 | } 844 | 845 | // Offset: `n` days ago 846 | if (std.ascii.isDigit(str[0]) and str.len <= 2) { 847 | const offset = std.fmt.parseInt(i64, str, 10) catch S.printDateHelpAndExit(str, label, "does not match any format"); 848 | var epoch = date.epochNowLocal() - offset * date.secs_per_day; 849 | return LocalDate.fromEpoch(epoch); 850 | } 851 | 852 | return LocalDate.parse(str) catch |err| { 853 | const msg = switch (err) { 854 | error.BadFormat => "does not match any format", 855 | error.InvalidCharacter => "does not match any format", 856 | error.YearBefore2022 => "year before 2022 not supported", 857 | error.MonthOutOfRange => scratchPrint("month '{s}' out of range", .{str[4..6]}), 858 | error.DayOutOfRange => scratchPrint("day '{s}' out of range", .{str[6..8]}), 859 | }; 860 | S.printDateHelpAndExit(str, label, msg); 861 | }; 862 | } 863 | 864 | const Range = struct { 865 | start: LocalDate, 866 | end: LocalDate, 867 | }; 868 | 869 | fn parseRangeOrExit(start_date_str: ?[]const u8, end_date_str: ?[]const u8) Range { 870 | const end_timestamp = if (end_date_str) |str| 871 | parseLocalDateOrExit(str, "end").toEpoch() 872 | else 873 | date.epochNow(); 874 | 875 | const start_timestamp = if (start_date_str) |str| 876 | parseLocalDateOrExit(str, "start").toEpoch() 877 | else 878 | end_timestamp - 30 * date.secs_per_day; 879 | 880 | if (start_timestamp >= end_timestamp) { 881 | printAndExit("Invalid date range: start date >= end date\n", .{}); 882 | } 883 | 884 | const n_days = @divFloor(end_timestamp - start_timestamp, date.secs_per_day); 885 | if (n_days > 50) { 886 | printAndExit("Invalid date range: range must be less than 50 days, got {d}\n", .{n_days}); 887 | } 888 | 889 | return .{ 890 | .start = LocalDate.epochToLocal(start_timestamp), 891 | .end = LocalDate.epochToLocal(end_timestamp), 892 | }; 893 | } 894 | 895 | fn parseChainIndexes(allocator: Allocator, chain_db: *ChainDb, str: []const u8) ![]IndexAndId { 896 | var cids = std.ArrayList(IndexAndId).init(allocator); 897 | var seen = std.AutoHashMap(u16, void).init(allocator); 898 | defer seen.deinit(); 899 | 900 | var index_iterator = std.mem.split(u8, str, ","); 901 | while (index_iterator.next()) |index| { 902 | // Range "-" 903 | if (std.mem.indexOf(u8, index, "-")) |dash_index| { 904 | const start = parseChainIndex(index[0..dash_index]); 905 | const end = parseChainIndex(index[dash_index + 1 ..]); 906 | if (start >= end) { 907 | printAndExit("Invalid chain index range: start {d} >= end {d}\n", .{ start, end }); 908 | } 909 | var i: u16 = start; 910 | while (i <= end) : (i += 1) { 911 | if (seen.contains(i)) continue; 912 | const cid = validateChainIndex(chain_db, i); 913 | try cids.append(.{ .index = i, .id = cid }); 914 | try seen.put(i, {}); 915 | } 916 | // Single "" 917 | } else { 918 | const cid_and_index = try parseAndValidateChainIndex(chain_db, index); 919 | if (!seen.contains(cid_and_index.index)) { 920 | try cids.append(cid_and_index); 921 | try seen.put(cid_and_index.index, {}); 922 | } 923 | } 924 | } 925 | return cids.toOwnedSlice(); 926 | } 927 | 928 | pub const Files = struct { 929 | chains: std.fs.File, 930 | links: std.fs.File, 931 | 932 | pub fn close(self: @This()) void { 933 | self.chains.close(); 934 | self.links.close(); 935 | } 936 | }; 937 | 938 | fn getConfigDirPath() ![]const u8 { 939 | switch (builtin.os.tag) { 940 | .windows => { 941 | const app_data_local = std.os.windows.GUID.parse("{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}"); 942 | var dir_path_ptr: [*:0]u16 = undefined; 943 | const hresult = std.os.windows.shell32.SHGetKnownFolderPath( 944 | &app_data_local, 945 | std.os.windows.KF_FLAG_CREATE, 946 | null, 947 | &dir_path_ptr, 948 | ); 949 | switch (hresult) { 950 | std.os.windows.S_OK => { 951 | defer std.os.windows.ole32.CoTaskMemFree(@ptrCast(dir_path_ptr)); 952 | return std.unicode.utf16leToUtf8Alloc(std.heap.c_allocator, std.mem.span(dir_path_ptr)); 953 | }, 954 | else => printAndExit("Could not open AppData/Local directory\n", .{}), 955 | } 956 | }, 957 | else => return std.os.getenv("HOME") orelse printAndExit("Could not find 'HOME' directory\n", .{}), 958 | } 959 | } 960 | 961 | pub fn openOrCreateDbFiles(data_dir_path: ?[]const u8, suffix: []const u8) !Files { 962 | var sow = std.io.getStdOut().writer(); 963 | 964 | var habu: struct { dir: std.fs.Dir, path: []const u8} = if (data_dir_path) |ddp| blk: { 965 | if (!std.fs.path.isAbsolute(ddp)) 966 | printAndExit("Expected absolute path, got '{s}'\n", .{ddp}); 967 | var data_dir = std.fs.openDirAbsolute(ddp, .{}) catch |err| switch (err) { 968 | error.FileNotFound => printAndExit("Could not open data dir at '{s}'\n", .{ddp}), 969 | else => return err, 970 | }; 971 | break :blk .{ .dir = data_dir, .path = ddp }; 972 | } else blk: { 973 | const config_dir_path = try getConfigDirPath(); 974 | var config_dir = try std.fs.openDirAbsolute(config_dir_path, .{}); 975 | defer config_dir.close(); 976 | 977 | const habu_dir_path = switch (builtin.os.tag) { 978 | .windows => "habu", 979 | else => ".habu", 980 | }; 981 | var habu_dir = config_dir.openDir(habu_dir_path, .{}) catch |err| switch (err) { 982 | error.FileNotFound => dir: { 983 | const dir = try config_dir.makeOpenPath(habu_dir_path, .{}); 984 | try sow.print("Created data dir at {s}/{s} (to remove habu, delete this directory)\n", .{config_dir_path, habu_dir_path}); 985 | break :dir dir; 986 | }, 987 | else => return err, 988 | }; 989 | break :blk .{ .dir = habu_dir, .path = habu_dir_path }; 990 | }; 991 | var habu_dir = habu.dir; 992 | const habu_dir_path = habu.path; 993 | defer habu_dir.close(); 994 | 995 | const chains_filename = scratchPrint("chains.bin{s}", .{suffix}); 996 | var chains = try habu_dir.createFile(chains_filename, .{ .read = true, .truncate = false, .lock = .exclusive }); 997 | { 998 | const stat = try chains.stat(); 999 | if (stat.size == 0) { 1000 | const meta = ChainMeta{ .id_counter = 0, .len = 0 }; 1001 | var w = chains.writer(); 1002 | try w.writeStruct(meta); 1003 | std.log.debug("Wrote {s} to {s}", .{chains_filename, habu_dir_path}); 1004 | } 1005 | } 1006 | 1007 | const links_filename = scratchPrint("links.bin{s}", .{suffix}); 1008 | var links = try habu_dir.createFile(links_filename, .{ .read = true, .truncate = false, .lock = .exclusive }); 1009 | { 1010 | const stat = try links.stat(); 1011 | if (stat.size == 0) { 1012 | const meta = LinkMeta{ .len = 0 }; 1013 | var w = links.writer(); 1014 | try w.writeStruct(meta); 1015 | std.log.debug("Wrote {s} to {s}", .{links_filename, habu_dir_path}); 1016 | } 1017 | } 1018 | 1019 | return .{ 1020 | .chains = chains, 1021 | .links = links, 1022 | }; 1023 | } 1024 | 1025 | const Show = enum { 1026 | all, 1027 | stopped, 1028 | active, 1029 | }; 1030 | 1031 | const Options = struct { 1032 | data_dir: ?[]const u8 = null, 1033 | transitions_str: ?[]const u8 = null, 1034 | override_now: ?i64 = null, 1035 | show: Show = .active, 1036 | }; 1037 | 1038 | fn parseOptionsAndPrepareArgs(allocator: Allocator, args: [][]const u8, options: *Options) ![][]const u8 { 1039 | var args_array = std.ArrayList([]const u8).fromOwnedSlice(allocator, args); 1040 | var i: usize = 0; 1041 | while (i < args_array.items.len) { 1042 | var hit = false; 1043 | const arg = args_array.items[i]; 1044 | if (std.mem.eql(u8, arg, "--data-dir")) { 1045 | if (i == args_array.items.len - 1) printAndExit("Missing path argument to --data-dir\n", .{}); 1046 | _ = args_array.orderedRemove(i); 1047 | options.data_dir = args_array.orderedRemove(i); 1048 | hit = true; 1049 | // Hidden option used for integration tests 1050 | } else if (std.mem.eql(u8, arg, "--transitions")) { 1051 | _ = args_array.orderedRemove(i); 1052 | options.transitions_str = args_array.orderedRemove(i); 1053 | hit = true; 1054 | } else if (std.mem.eql(u8, arg, "--now")) { 1055 | _ = args_array.orderedRemove(i); 1056 | options.override_now = try std.fmt.parseInt(i64, args_array.orderedRemove(i), 10); 1057 | hit = true; 1058 | } else if (std.mem.eql(u8, arg, "--show")) { 1059 | if (i == args_array.items.len - 1) printAndExit("Missing argument to --show, expected one of {s}\n", .{try formatEnumValuesForPrint(Show, allocator)}); 1060 | _ = args_array.orderedRemove(i); 1061 | const show_str = args_array.orderedRemove(i); 1062 | options.show = std.meta.stringToEnum(Show, show_str) orelse 1063 | printAndExit("Invalid argument to --show, expected one of {s}, got {s}\n", .{try formatEnumValuesForPrint(Show, allocator), show_str}); 1064 | hit = true; 1065 | } 1066 | if (!hit) 1067 | i += 1; 1068 | } 1069 | // Remove path to binary from args to make args zero-indexed. 1070 | _ = args_array.orderedRemove(0); 1071 | return args_array.toOwnedSlice(); 1072 | } 1073 | 1074 | pub fn main() !void { 1075 | const old_code_page = if (builtin.os.tag == .windows) std.os.windows.kernel32.GetConsoleOutputCP() else undefined; 1076 | if (builtin.os.tag == .windows) 1077 | _ = std.os.windows.kernel32.SetConsoleOutputCP(65001); 1078 | 1079 | var c_allocator = std.heap.c_allocator; 1080 | var arena = std.heap.ArenaAllocator.init(c_allocator); 1081 | var allocator = arena.allocator(); 1082 | defer arena.deinit(); 1083 | 1084 | const start = try std.time.Instant.now(); 1085 | defer { 1086 | const end = std.time.Instant.now() catch unreachable; 1087 | std.log.debug("finished in {}", .{std.fmt.fmtDuration(end.since(start))}); 1088 | } 1089 | 1090 | var stdout_writer = std.io.getStdOut().writer(); 1091 | var buffered_writer = std.io.bufferedWriter(stdout_writer); 1092 | var sow = buffered_writer.writer(); 1093 | defer buffered_writer.flush() catch panic(); 1094 | 1095 | var args: [][]const u8 = try std.process.argsAlloc(allocator); 1096 | var options: Options = .{}; 1097 | args = try parseOptionsAndPrepareArgs(allocator, args, &options); 1098 | 1099 | try date.initTransitions(allocator, options.transitions_str); 1100 | if (options.override_now) |now| { 1101 | date.overrideNow(now); 1102 | } 1103 | 1104 | var files = try openOrCreateDbFiles(options.data_dir, ""); 1105 | defer files.close(); 1106 | var chain_db = ChainDb{ .allocator = allocator, .file = files.chains, .show = options.show }; 1107 | var link_db = LinkDb{ .allocator = allocator, .file = files.links }; 1108 | 1109 | const command = if (optionalArg(args, 0)) |command_str| 1110 | parseCommandOrExit(command_str) 1111 | else 1112 | .display; 1113 | 1114 | switch (command) { 1115 | .add => { 1116 | try checkNumberOfArgs(allocator, args, 3); 1117 | const name = expectArg(args, 1, "name"); 1118 | validateNameLen(name); 1119 | 1120 | const kind_str = expectArg(args, 2, "kind"); 1121 | const kind = std.meta.stringToEnum(Kind, kind_str) orelse 1122 | printAndExit("Invalid kind '{s}', expected one of {s}\n", .{trunc(kind_str), try formatEnumValuesForPrint(Kind, allocator)}); 1123 | 1124 | const min_days = blk: { 1125 | if (kind == .weekly) { 1126 | const min_days_str = expectArg(args, 3, "min_days"); 1127 | break :blk parseMinDaysOrExit(min_days_str); 1128 | } else { 1129 | break :blk 0; 1130 | } 1131 | }; 1132 | 1133 | try chain_db.materialize(); 1134 | 1135 | var chain = Chain{ 1136 | .id = chain_db.meta.id_counter, 1137 | .name = std.mem.zeroes([128]u8), 1138 | .name_len = @as(u16, @intCast(name.len)), 1139 | .kind = kind, 1140 | .color = color.colors[chain_db.meta.len % color.colors.len], 1141 | .min_days = min_days, 1142 | .created = date.epochNow(), 1143 | .stopped = ACTIVE, 1144 | .n_tags = 0, 1145 | .tags = std.mem.zeroes([max_tags]Tag), 1146 | }; 1147 | std.mem.copy(u8, &chain.name, name); 1148 | 1149 | try chain_db.add(&chain); 1150 | try chain_db.persist(); 1151 | 1152 | try link_db.materialize(chain_db.meta.len); 1153 | const chains = chain_db.getChains(); 1154 | const range = parseRangeOrExit(null, null); 1155 | const links = link_db.getAndSortLinks(range.start); 1156 | try tui.drawChains(chains, links, range.start, range.end); 1157 | 1158 | }, 1159 | .info => { 1160 | try checkNumberOfArgs(allocator, args, 2); 1161 | try chain_db.materialize(); 1162 | const index_str = expectArg(args, 1, "index"); 1163 | const cid_and_index = try parseAndValidateChainIndex(&chain_db, index_str); 1164 | var chain = chain_db.getByIndex(cid_and_index.index); 1165 | 1166 | try link_db.materialize(chain_db.meta.len); 1167 | 1168 | const date_arg = optionalArg(args, 2); 1169 | // Show link info 1170 | if (date_arg) |str| { 1171 | const link_date = parseLocalDateOrExit(str, "link"); 1172 | const chain_links = link_db.getLinksForChain(cid_and_index.id, link_date.prev()); 1173 | const result = LinkDb.getInsertIndex(chain_links, cid_and_index.id, link_date.toEpoch()); 1174 | if (!result.occupied) 1175 | printAndExit("No link found at date '{s}' for chain {d}\n", .{trunc(str), cid_and_index.index}); 1176 | 1177 | try tui.drawLinkInfo(chain, chain_links, result.index); 1178 | } else { // Show chain info 1179 | const range = if (chain.isActive()) 1180 | parseRangeOrExit(null, null) 1181 | else Range{ 1182 | .start = LocalDate.fromEpoch(chain.stopped - 30 * date.secs_per_day), 1183 | .end = LocalDate.fromEpoch(chain.stopped), 1184 | }; 1185 | const chain_links = link_db.getLinksForChain(cid_and_index.id, null); 1186 | const stats = try computeStats(allocator, chain, chain_links); 1187 | try tui.drawChainInfo(chain, chain_links, &stats, range.start, range.end); 1188 | } 1189 | }, 1190 | .@"export" => { 1191 | try checkNumberOfArgs(allocator, args, 0); 1192 | try chain_db.materialize(); 1193 | const chains = chain_db.getChains(); 1194 | 1195 | try link_db.materialize(chain_db.meta.len); 1196 | var links = link_db.getAndSortLinks(null); 1197 | 1198 | var jw = std.json.writeStreamMaxDepth(sow, .{ .whitespace = .indent_4 }, 8); 1199 | 1200 | try jw.beginArray(); 1201 | for (chains) |chain| { 1202 | try jw.beginObject(); 1203 | 1204 | try jw.objectField("id"); 1205 | try jw.write(chain.id); 1206 | 1207 | try jw.objectField("name"); 1208 | try jw.write(chain.name[0..chain.name_len]); 1209 | 1210 | try jw.objectField("created"); 1211 | try jw.write(chain.created); 1212 | 1213 | try jw.objectField("stopped"); 1214 | try jw.write(chain.stopped); 1215 | 1216 | try jw.objectField("kind"); 1217 | try jw.write(@tagName(chain.kind)); 1218 | 1219 | try jw.objectField("color"); 1220 | try jw.write(&chain.color.toHex()); 1221 | 1222 | try jw.objectField("min_days"); 1223 | try jw.write(chain.min_days); 1224 | 1225 | if (chain.n_tags > 0) { 1226 | try jw.objectField("tags"); 1227 | try jw.beginArray(); 1228 | var i: usize = 0; 1229 | while (i < chain.n_tags) : (i += 1) { 1230 | const tag = chain.tags[i]; 1231 | try jw.beginObject(); 1232 | try jw.objectField("id"); 1233 | try jw.write(tag.header.id); 1234 | try jw.objectField("name"); 1235 | try jw.write(tag.getName()); 1236 | try jw.endObject(); 1237 | } 1238 | try jw.endArray(); 1239 | } 1240 | 1241 | { 1242 | try jw.objectField("links"); 1243 | try jw.beginArray(); 1244 | var i: usize = 0; 1245 | while (i < links.len and links[i].chain_id != chain.id) : (i += 1) {} 1246 | while (i < links.len) : (i += 1) { 1247 | const link = links[i]; 1248 | if (link.chain_id != chain.id) 1249 | break; 1250 | try jw.beginObject(); 1251 | try jw.objectField("timestamp"); 1252 | try jw.write(link.timestamp); 1253 | try jw.endObject(); 1254 | } 1255 | try jw.endArray(); 1256 | } 1257 | 1258 | try jw.endObject(); 1259 | } 1260 | try jw.endArray(); 1261 | try sow.writeByte('\n'); 1262 | }, 1263 | .import => { 1264 | try checkNumberOfArgs(allocator, args, 0); 1265 | var r = std.io.getStdIn().reader(); 1266 | const bytes = try r.readAllAlloc(allocator, 200_000); 1267 | var root = (try std.json.parseFromSliceLeaky(std.json.Value, allocator, bytes, .{})); 1268 | 1269 | var all_chains = std.ArrayList(Chain).init(allocator); 1270 | var all_links = std.ArrayList(Link).init(allocator); 1271 | 1272 | var chain_meta = ChainMeta{ 1273 | .id_counter = 0, 1274 | .len = 0, 1275 | }; 1276 | var link_meta = LinkMeta{ 1277 | .len = 0, 1278 | }; 1279 | for (root.array.items) |v| { 1280 | const chain_object = v.object; 1281 | const name = chain_object.get("name").?.string; 1282 | var name_buf = std.mem.zeroes([chain_name_max_len]u8); 1283 | std.mem.copy(u8, &name_buf, name); 1284 | 1285 | const n_tags: u8 = if (chain_object.get("n_tags")) |n| @intCast(n.integer) else 0; 1286 | var tags = std.mem.zeroes([4]Tag); 1287 | if (n_tags > 0) { 1288 | const tag_objects = chain_object.get("tags").?.array; 1289 | for (tag_objects.items, 0..) |value, i| { 1290 | const tag_object = value.object; 1291 | tags[i] = Tag{ 1292 | .header = .{ 1293 | .id = @as(u3, @intCast(tag_object.get("id").?.integer)), 1294 | .len = undefined, 1295 | }, 1296 | .name = undefined, 1297 | }; 1298 | const tag_name = tag_object.get("name").?.string; 1299 | std.mem.copy(u8, &tags[i].name, tag_name); 1300 | tags[i].header.len = @as(u5, @intCast(tag_name.len)); 1301 | } 1302 | } 1303 | 1304 | const chain = Chain{ 1305 | .name = name_buf, 1306 | .created = @as(i64, @intCast(chain_object.get("created").?.integer)), 1307 | .stopped = @as(i64, @intCast(chain_object.get("stopped").?.integer)), 1308 | .color = try color.Rgb.fromHex(chain_object.get("color").?.string[1..]), 1309 | .id = @as(u16, @intCast(chain_object.get("id").?.integer)), 1310 | .name_len = @as(u16, @intCast(name.len)), 1311 | .kind = std.meta.stringToEnum(Kind, chain_object.get("kind").?.string).?, 1312 | .min_days = @as(u8, @intCast(chain_object.get("min_days").?.integer)), // `0` means N/A 1313 | .n_tags = n_tags, 1314 | .tags = tags, 1315 | }; 1316 | 1317 | chain_meta.len += 1; 1318 | chain_meta.id_counter = @max(chain_meta.id_counter, chain.id); 1319 | 1320 | try all_chains.append(chain); 1321 | 1322 | const links = chain_object.get("links").?.array; 1323 | for (links.items) |lv| { 1324 | const link_object = lv.object; 1325 | const link = Link{ 1326 | .chain_id = chain.id, 1327 | .tags = 0, 1328 | .timestamp = @as(i64, @intCast(link_object.get("timestamp").?.integer)), 1329 | }; 1330 | 1331 | link_meta.len += 1; 1332 | 1333 | try all_links.append(link); 1334 | } 1335 | } 1336 | 1337 | std.sort.pdq(Link, all_links.items, {}, orderLinksTimestamp); 1338 | 1339 | chain_meta.id_counter += 1; 1340 | 1341 | var imported_files = try openOrCreateDbFiles(options.data_dir, ".import"); 1342 | defer imported_files.close(); 1343 | 1344 | var cw = imported_files.chains.writer(); 1345 | try imported_files.chains.seekTo(0); 1346 | try cw.writeStruct(chain_meta); 1347 | const chain_bytes = std.mem.sliceAsBytes(all_chains.items); 1348 | try cw.writeAll(chain_bytes); 1349 | try imported_files.chains.setEndPos(@sizeOf(ChainMeta) + chain_bytes.len); 1350 | 1351 | var lw = imported_files.links.writer(); 1352 | try imported_files.links.seekTo(0); 1353 | try lw.writeStruct(link_meta); 1354 | const link_bytes = std.mem.sliceAsBytes(all_links.items); 1355 | try lw.writeAll(link_bytes); 1356 | try imported_files.links.setEndPos(@sizeOf(LinkMeta) + link_bytes.len); 1357 | }, 1358 | .display => { 1359 | try checkNumberOfArgs(allocator, args, 2); 1360 | try chain_db.materialize(); 1361 | if (chain_db.meta.len == 0) { 1362 | printAndExit("No chains to display, use `habu add` to add a chain or `habu help` for a full list of commands.\n", .{}); 1363 | } 1364 | const chains = chain_db.getChains(); 1365 | 1366 | const range = parseRangeOrExit(optionalArg(args, 1), optionalArg(args, 2)); 1367 | 1368 | try link_db.materialize(chain_db.meta.len); 1369 | const links = link_db.getAndSortLinks(range.start); 1370 | 1371 | try tui.drawChains(chains, links, range.start, range.end); 1372 | }, 1373 | .help => { 1374 | try checkNumberOfArgs(allocator, args, 1); 1375 | const sub_command = if (optionalArg(args, 1)) |str| 1376 | parseCommandOrExit(str) 1377 | else 1378 | printHelpAndExit(null); 1379 | 1380 | const help_str = help.commandHelp(sub_command); 1381 | if (help_str) |str| { 1382 | try sow.writeAll(str); 1383 | } else { 1384 | printAndExit("No additional help avaiable for subcommand '{s}'\n", .{@tagName(sub_command)}); 1385 | } 1386 | }, 1387 | .modify => { 1388 | try checkNumberOfArgs(allocator, args, 3); 1389 | try chain_db.materialize(); 1390 | const index_str = expectArg(args, 1, "id"); 1391 | const cid_and_index = try parseAndValidateChainIndex(&chain_db, index_str); 1392 | 1393 | const field = expectArg(args, 2, "field"); 1394 | const value = expectArg(args, 3, "value"); 1395 | var chain = chain_db.getByIndex(cid_and_index.index); 1396 | 1397 | if (std.mem.eql(u8, field, "color")) { 1398 | const new_color = try color.Rgb.fromHex(value); 1399 | chain.color = new_color; 1400 | } else if (std.mem.eql(u8, field, "name")) { 1401 | validateNameLen(value); 1402 | var name_buf = std.mem.zeroes([chain_name_max_len]u8); 1403 | std.mem.copy(u8, &name_buf, value); 1404 | chain.name = name_buf; 1405 | chain.name_len = @as(u16, @intCast(value.len)); 1406 | } else if (std.mem.eql(u8, field, "min_days")) { 1407 | if (chain.kind != .weekly) { 1408 | try printHelpAndExit("Cannot modify min_days of non-weekly chain"); 1409 | } 1410 | chain.min_days = parseMinDaysOrExit(value); 1411 | } else if (std.mem.eql(u8, field, "tags")) { 1412 | const op = value; 1413 | if (std.mem.eql(u8, op, "add")) { 1414 | if (chain.n_tags >= max_tags) { 1415 | printAndExit("Max tags ({d}) exceeded\n", .{max_tags}); 1416 | } 1417 | 1418 | const name = expectArg(args, 4, "name"); 1419 | validateTagNameLen(name); 1420 | var name_buf = std.mem.zeroes([tag_name_max_len]u8); 1421 | std.mem.copy(u8, &name_buf, name[0..@min(name.len, name_buf.len)]); 1422 | chain.tags[chain.n_tags] = Tag{ 1423 | .header = .{ 1424 | .id = @as(u3, @intCast(chain.maxTagsId() + 1)), 1425 | .len = @as(u5, @intCast(name.len)), 1426 | }, 1427 | .name = name_buf, 1428 | }; 1429 | chain.n_tags += 1; 1430 | } else if (std.mem.eql(u8, op, "delete")) { 1431 | printAndExit("TODO: implement tag delete\n", .{}); 1432 | } else if (std.mem.eql(u8, op, "rename")) { 1433 | printAndExit("TODO: implement tag rename\n", .{}); 1434 | } 1435 | } else if (std.mem.eql(u8, field, "stopped")) { 1436 | chain.stopped = if (std.mem.eql(u8, value, "false")) 1437 | ACTIVE 1438 | else 1439 | parseLocalDateOrExit(value, "stop").toEpoch(); 1440 | chain_db.refilter(); 1441 | } else { 1442 | const msg = scratchPrint("Invalid field '{s}', expected one of: color, name, min_days\n", .{trunc(field)}); 1443 | try printHelpAndExit(msg); 1444 | } 1445 | 1446 | try chain_db.persist(); 1447 | 1448 | try link_db.materialize(chain_db.meta.len); 1449 | const chains = chain_db.getChains(); 1450 | const range = parseRangeOrExit(null, null); 1451 | const links = link_db.getAndSortLinks(range.start); 1452 | try tui.drawChains(chains, links, range.start, range.end); 1453 | }, 1454 | .delete => { 1455 | try checkNumberOfArgs(allocator, args, 1); 1456 | try chain_db.materialize(); 1457 | const index_str = expectArg(args, 1, "index"); 1458 | const cid_and_index = try parseAndValidateChainIndex(&chain_db, index_str); 1459 | 1460 | chain_db.delete(cid_and_index.index); 1461 | try chain_db.persist(); 1462 | 1463 | try link_db.materialize(chain_db.meta.len); 1464 | const chains = chain_db.getChains(); 1465 | const range = parseRangeOrExit(null, null); 1466 | const links = link_db.getAndSortLinks(range.start); 1467 | try tui.drawChains(chains, links, range.start, range.end); 1468 | }, 1469 | .link, .unlink => { 1470 | try chain_db.materialize(); 1471 | try link_db.materialize(chain_db.meta.len); 1472 | 1473 | const index_str = expectArg(args, 1, "index"); 1474 | const cids_and_indexes = try parseChainIndexes(allocator, &chain_db, index_str); 1475 | 1476 | const start_date_str = optionalArg(args, 2); 1477 | const timestamp = if (start_date_str) |start_date| 1478 | parseLocalDateOrExit(start_date, "link").midnightInLocal() 1479 | else 1480 | date.epochNow(); 1481 | 1482 | for (cids_and_indexes) |cid_and_index| { 1483 | switch (command) { 1484 | .link => { 1485 | try checkNumberOfArgs(allocator, args, 3); 1486 | var link = Link{ 1487 | .chain_id = cid_and_index.id, 1488 | .tags = 0, 1489 | .timestamp = timestamp, 1490 | }; 1491 | 1492 | const tags_str = optionalArg(args, 3); 1493 | if (tags_str) |str| { 1494 | const chain = chain_db.getByIndex(cid_and_index.index); 1495 | var it = std.mem.split(u8, str, ","); 1496 | link.tags = chain.tagsFromNames(&it); 1497 | } 1498 | 1499 | try link_db.add(link); 1500 | }, 1501 | .unlink => { 1502 | try checkNumberOfArgs(allocator, args, 2); 1503 | const removed = link_db.remove(cid_and_index.id, timestamp); 1504 | if (!removed) { 1505 | const date_str = start_date_str orelse &LocalDate.fromEpoch(timestamp).asString(); 1506 | try sow.print("No link found on {s}\n", .{date_str}); 1507 | } 1508 | }, 1509 | else => unreachable, 1510 | } 1511 | } 1512 | 1513 | buffered_writer.flush() catch panic(); 1514 | 1515 | try link_db.persist(); 1516 | 1517 | const chains = chain_db.getChains(); 1518 | const range = parseRangeOrExit(null, null); 1519 | const links = link_db.getAndSortLinks(range.start); 1520 | try tui.drawChains(chains, links, range.start, range.end); 1521 | }, 1522 | .tag => { 1523 | try checkNumberOfArgs(allocator, args, 3); 1524 | const index_str = expectArg(args, 1, "index"); 1525 | try chain_db.materialize(); 1526 | const cid_and_index = try parseAndValidateChainIndex(&chain_db, index_str); 1527 | 1528 | const date_str = expectArg(args, 2, "date"); 1529 | const link_date = parseLocalDateOrExit(date_str, "link").toEpoch(); 1530 | 1531 | try link_db.materialize(chain_db.meta.len); 1532 | var result = LinkDb.getInsertIndex(link_db.links.items, cid_and_index.id, link_date); 1533 | if (!result.occupied) 1534 | printAndExit("No link found at date '{s}' for chain {d}\n", .{trunc(date_str), cid_and_index.index}); 1535 | var index = result.index; 1536 | 1537 | var link = &link_db.links.items[index]; 1538 | link_db.first_write_index = index; 1539 | 1540 | const chain = chain_db.getByIndex(cid_and_index.index); 1541 | if (chain.n_tags == 0) { 1542 | printAndExit("Chain '{d}' has no tags\n", .{chain.id}); 1543 | } 1544 | 1545 | const tag_names = expectArg(args, 3, "tags"); 1546 | var it = std.mem.split(u8, tag_names, ","); 1547 | link.tags = chain.tagsFromNames(&it); 1548 | 1549 | try link_db.persist(); 1550 | }, 1551 | .version => { 1552 | try sow.print("version: {s}\n", .{build_options.version}); 1553 | try sow.print("commit hash: {s}\n", .{build_options.git_commit_hash}); 1554 | if (optionalArg(args, 1)) |verbose| { 1555 | if (std.mem.eql(u8, verbose, "-v")) { 1556 | const offset_ns = date.utcOffset(date.epochNowLocal()) * std.time.ns_per_s; 1557 | try sow.print("UTC offset: {}\n", .{std.fmt.fmtDurationSigned(offset_ns)}); 1558 | } 1559 | } 1560 | } 1561 | } 1562 | 1563 | if (builtin.os.tag == .windows) 1564 | _ = std.os.windows.kernel32.SetConsoleOutputCP(old_code_page); 1565 | } 1566 | 1567 | test { 1568 | _ = @import("date.zig"); 1569 | _ = @import("test.zig"); 1570 | } 1571 | -------------------------------------------------------------------------------- /src/test.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const date = @import("date.zig"); 3 | const main = @import("main.zig"); 4 | const color = @import("color.zig"); 5 | 6 | const testing = std.testing; 7 | const expect = testing.expect; 8 | const expectEqual = testing.expectEqual; 9 | const expectEqualSlices = testing.expectEqualSlices; 10 | 11 | const Allocator = std.mem.Allocator; 12 | const LocalDate = date.LocalDate; 13 | const ChainDb = main.ChainDb; 14 | const LinkDb = main.LinkDb; 15 | const Link = main.Link; 16 | 17 | var allocator = std.heap.c_allocator; 18 | const print_output = false; 19 | 20 | test "basic" { 21 | // now = 2024-01-26T00:00:00Z 22 | var db = try TestDb.init(.{ .override_now = 1706227200 }); 23 | defer db.deinit(); 24 | 25 | try run(db, "add Foo daily"); 26 | try run(db, "link 1 20240101"); 27 | 28 | { 29 | db.loadFiles(); 30 | defer db.unloadFiles(); 31 | 32 | var chain_db = db.chainDb(); 33 | try chain_db.materialize(); 34 | const meta = chain_db.meta; 35 | try expectEqual(@as(u16, 1), meta.id_counter); 36 | try expectEqual(@as(u16, 1), meta.len); 37 | try expectEqual(@as(u32, 0), meta._padding); 38 | 39 | try expectEqual(chain_db.chains.items.len, 1); 40 | const chain = chain_db.chains.items[0]; 41 | 42 | try expectEqualSlices(u8, "Foo", chain.name[0..chain.name_len]); 43 | try expectEqual(@as(i64, 1706227200), chain.created); 44 | try expectEqual(@as(u16, 0), chain.id); 45 | var k: main.Kind = .daily; 46 | try expectEqual(k, chain.kind); 47 | try expectEqual(color.Rgb{ .r = 126, .g = 122, .b = 245 }, chain.color); 48 | try expectEqual(@as(u8, 0), chain.min_days); 49 | try expectEqual(@as(i64, 0), chain.min_days); 50 | try expectEqual(@as(u8, 0), chain.n_tags); 51 | for (chain.tags) |tag| { 52 | try expectEqual(@as(main.Tag, @bitCast(@as(u128, 0))), tag); 53 | } 54 | try expectEqual(@as(i64, 0), chain.stopped); 55 | 56 | var link_db = db.linkDb(); 57 | try link_db.materialize(chain_db.meta.len); 58 | 59 | try expectEqual(link_db.links.items.len, 1); 60 | try expectEqual(@as(i64, 0), link_db.links.items[0].chain_id); 61 | try expectEqual(@as(i64, 1704063600), link_db.links.items[0].timestamp); 62 | } 63 | 64 | try run(db, "delete 1"); 65 | { 66 | db.loadFiles(); 67 | defer db.unloadFiles(); 68 | 69 | var chain_db = db.chainDb(); 70 | try chain_db.materialize(); 71 | const meta = chain_db.meta; 72 | try expectEqual(@as(u16, 1), meta.id_counter); 73 | try expectEqual(@as(u16, 0), meta.len); 74 | try expectEqual(@as(u32, 0), meta._padding); 75 | try expectEqual(chain_db.chains.items.len, 0); 76 | } 77 | try expectErrorMessage(db, "No chain found with index '1'", "link 1 20240101", .exact); 78 | } 79 | 80 | test "linking same day twice" { 81 | var db = try TestDb.init(.{}); 82 | defer db.deinit(); 83 | 84 | try run(db, "add foo daily"); 85 | try run(db, "link 1 20240101"); 86 | 87 | const links = &.{ Link{ .chain_id = 0, .timestamp = 1704063600 }}; 88 | try expectLinks(&db, links); 89 | try expectErrorMessage(db, "Link already exists on date 2024-01-01, skipping", "link 1 20240101", .exact); 90 | try expectLinks(&db, links); 91 | } 92 | 93 | test "link/unlink stress test" { 94 | var db = try TestDb.init(.{}); 95 | defer db.deinit(); 96 | 97 | var seed: [8]u8 = undefined; 98 | std.crypto.random.bytes(&seed); 99 | var prng = std.rand.DefaultPrng.init(@bitCast(seed)); 100 | var random = prng.random(); 101 | 102 | const S = struct { 103 | chain_id: u16, 104 | date_str: []const u8, 105 | timestamp: i64, 106 | }; 107 | 108 | var year = blk: { 109 | var dates: [365]S = undefined; 110 | var i: usize = 0; 111 | for (year_2023) |day| { 112 | const chain_id = random.uintLessThan(u16, 5); 113 | dates[i] = .{ .chain_id = chain_id, .date_str = day.date_str, .timestamp = day.timestamp }; 114 | i += 1; 115 | } 116 | break :blk dates; 117 | }; 118 | 119 | const links = blk: { 120 | var links: [365]Link = undefined; 121 | for (year, 0..) |day, i| { 122 | links[i] = Link{ .chain_id = day.chain_id, .timestamp = day.timestamp }; 123 | } 124 | break :blk links; 125 | }; 126 | 127 | try run(db, "add c1 daily"); 128 | try run(db, "add c2 daily"); 129 | try run(db, "add c3 weekly 4"); 130 | try run(db, "add c4 daily"); 131 | try run(db, "add c5 weekly 3"); 132 | 133 | random.shuffle(S, &year); 134 | 135 | var buf = std.mem.zeroes([30]u8); 136 | for (year) |day| { 137 | const input = try std.fmt.bufPrint(&buf, "link {d} {s}", .{day.chain_id + 1, day.date_str}); 138 | try run(db, input); 139 | } 140 | try expectLinks(&db, &links); 141 | 142 | var links_array = std.ArrayList(Link).fromOwnedSlice(allocator, try allocator.dupe(Link, &links)); 143 | random.shuffle(S, &year); 144 | for (year) |day| { 145 | const input = try std.fmt.bufPrint(&buf, "unlink {d} {s}", .{day.chain_id + 1, day.date_str}); 146 | try run(db, input); 147 | for (links_array.items, 0..) |link, j| { 148 | if (link.timestamp == day.timestamp) { 149 | _ = links_array.orderedRemove(j); 150 | break; 151 | } 152 | } 153 | try expectLinks(&db, links_array.items); 154 | } 155 | } 156 | 157 | test "link now" { 158 | // now = 2024-01-26T00:00:00Z 159 | var db = try TestDb.init(.{ .override_now = 1706227200 }); 160 | defer db.deinit(); 161 | 162 | try run(db, "add foo daily"); 163 | try run(db, "link 1"); 164 | try expectLinks(&db, &.{Link{ .chain_id = 0, .timestamp = 1706227200 }}); 165 | } 166 | 167 | test "relative dates" { 168 | // now = 2024-01-26T00:00:00Z 169 | var db = try TestDb.init(.{ .override_now = 1706227200 }); 170 | defer db.deinit(); 171 | 172 | try run(db, "add foo daily"); 173 | 174 | try run(db, "link 1 10"); // 2024-01-16 175 | try run(db, "link 1 11"); // 2024-01-15 176 | try run(db, "link 1 12"); // 2024-01-14 177 | 178 | try run(db, "link 1 mon"); // 2024-01-22 179 | try run(db, "link 1 tue"); // 2024-01-23 180 | try run(db, "link 1 wed"); // 2024-01-24 181 | try run(db, "link 1 thu"); // 2024-01-25 182 | try run(db, "link 1 fri"); // 2024-01-26 183 | try run(db, "link 1 sat"); // 2024-01-20 184 | try run(db, "link 1 sun"); // 2024-01-21 185 | 186 | try run(db, "link 1 1st"); // 2024-01-01 187 | try run(db, "link 1 2nd"); // 2024-01-02 188 | try run(db, "link 1 3rd"); // 2024-01-03 189 | try run(db, "link 1 10th"); // 2024-01-10 190 | 191 | const expected = [_]Link{ 192 | Link{ .chain_id = 0, .timestamp = 1704063600 }, // 2024-01-01 193 | Link{ .chain_id = 0, .timestamp = 1704150000 }, // 2024-01-02 194 | Link{ .chain_id = 0, .timestamp = 1704236400 }, // 2024-01-03 195 | Link{ .chain_id = 0, .timestamp = 1704841200 }, // 2024-01-10 196 | 197 | Link{ .chain_id = 0, .timestamp = 1705186800 }, // 2024-01-14 198 | Link{ .chain_id = 0, .timestamp = 1705273200 }, // 2024-01-15 199 | Link{ .chain_id = 0, .timestamp = 1705359600 }, // 2024-01-16 200 | 201 | Link{ .chain_id = 0, .timestamp = 1705705200 }, // 2024-01-20 202 | Link{ .chain_id = 0, .timestamp = 1705791600 }, // 2024-01-21 203 | Link{ .chain_id = 0, .timestamp = 1705878000 }, // 2024-01-22 204 | Link{ .chain_id = 0, .timestamp = 1705964400 }, // 2024-01-23 205 | Link{ .chain_id = 0, .timestamp = 1706050800 }, // 2024-01-24 206 | Link{ .chain_id = 0, .timestamp = 1706137200 }, // 2024-01-25 207 | Link{ .chain_id = 0, .timestamp = 1706223600 }, // 2024-01-26 208 | }; 209 | 210 | try expectLinks(&db, &expected); 211 | } 212 | 213 | test "parse date error" { 214 | var db = try TestDb.init(.{}); 215 | defer db.deinit(); 216 | 217 | try run(db, "add foo daily"); 218 | 219 | try expectErrorMessage(db, "Invalid link date '2023011', does not match any format", "link 1 2023011", .exact); 220 | try expectErrorMessage(db, "Invalid link date '-123', does not match any format", "link 1 -123", .exact); 221 | try expectErrorMessage(db, "Invalid link date 'asdf', does not match any format", "link 1 asdf", .exact); 222 | try expectErrorMessage(db, "Invalid link date '100', does not match any format", "link 1 100", .exact); 223 | try expectErrorMessage(db, "Invalid link date '0th', does not match any format", "link 1 0th", .exact); 224 | try expectErrorMessage(db, "Invalid link date '20100101', year before 2022 not supported", "link 1 20100101", .exact); 225 | 226 | try expectErrorMessage(db, "Invalid link date '99th'", "link 1 99th", .prefix); 227 | } 228 | 229 | test "error messages" { 230 | // now = 2024-01-26T00:00:00Z 231 | var db = try TestDb.init(.{ .override_now = 1706227200 }); 232 | defer db.deinit(); 233 | 234 | try expectErrorMessage(db, "Expected 'kind' argument", "add foo", .exact); 235 | try expectErrorMessage(db, "No chain found with index '1'", "link 1", .exact); 236 | try expectErrorMessage(db, "No chain found with index '1'", "unlink 1", .exact); 237 | 238 | try run(db, "add foo daily"); 239 | try expectErrorMessage(db, "No link found on 2024-01-26", "unlink 1", .exact); 240 | } 241 | 242 | const TestDb = struct { 243 | tmpdir: testing.TmpDir, 244 | path: []const u8, 245 | files: ?main.Files, 246 | override_now: ?i64, 247 | 248 | const Self = @This(); 249 | 250 | fn init(args: struct { override_now: ?i64 = null}) !Self { 251 | const tmpdir = testing.tmpDir(.{}); 252 | _ = try tmpdir.dir.makeOpenPath("db", .{}); 253 | return .{ 254 | .tmpdir = tmpdir, 255 | .files = null, 256 | .path = try tmpdir.dir.realpathAlloc(allocator, "db"), 257 | .override_now = args.override_now, 258 | }; 259 | } 260 | 261 | fn linkDb(self: *Self) LinkDb { 262 | return main.LinkDb{ .allocator = allocator, .file = self.files.?.links }; 263 | } 264 | 265 | fn chainDb(self: *Self) ChainDb { 266 | return main.ChainDb{ .allocator = allocator, .file = self.files.?.chains, .show = .all }; 267 | } 268 | 269 | fn loadFiles(self: *Self) void { 270 | if (self.files) |_| { 271 | return; 272 | } else { 273 | self.files = main.openOrCreateDbFiles(self.path, "") catch unreachable; 274 | } 275 | } 276 | 277 | fn unloadFiles(self: *Self) void { 278 | if (self.files) |*fs| fs.close(); 279 | self.files = null; 280 | } 281 | 282 | fn deinit(self: *Self) void { 283 | if (self.files) |*fs| fs.close(); 284 | self.tmpdir.cleanup(); 285 | allocator.free(self.path); 286 | } 287 | }; 288 | 289 | fn expectLinks(db: *TestDb, expected: []const Link) !void { 290 | db.loadFiles(); 291 | defer db.unloadFiles(); 292 | 293 | var link_db = db.linkDb(); 294 | try link_db.materialize(1); 295 | 296 | const meta = link_db.meta; 297 | try expectEqual(@as(u16, @intCast(expected.len)), meta.len); 298 | 299 | try expectEqualSlices( 300 | Link, 301 | expected, 302 | link_db.links.items 303 | ); 304 | } 305 | 306 | fn expectErrorMessage(db: TestDb, message: []const u8, input: []const u8, mode: enum { exact, prefix }) !void { 307 | const result = try runCapture(db, input); 308 | defer allocator.free(result.stdout); 309 | defer allocator.free(result.stderr); 310 | var it = std.mem.split(u8, result.stdout, "\n"); 311 | switch (mode) { 312 | .exact => try expectEqualSlices(u8, message, it.next().?), 313 | .prefix => try expect(std.mem.startsWith(u8, it.next().?, message)), 314 | } 315 | } 316 | 317 | fn run(db: TestDb, input: []const u8) !void { 318 | var result = try runCapture(db, input); 319 | allocator.free(result.stdout); 320 | allocator.free(result.stderr); 321 | } 322 | 323 | fn runCapture(db: TestDb, input: []const u8) !std.ChildProcess.ExecResult { 324 | var argv = std.ArrayList([]const u8).init(allocator); 325 | defer argv.deinit(); 326 | 327 | try argv.append("./zig-out/bin/habu"); 328 | try argv.append("--data-dir"); 329 | try argv.append(db.path); 330 | try argv.append("--transitions"); 331 | try argv.append(europe_stockholm_transitions_json); 332 | if (db.override_now) |now| { 333 | try argv.append("--now"); 334 | try argv.append(try std.fmt.allocPrint(allocator, "{d}", .{now})); 335 | } 336 | 337 | var it = std.mem.split(u8, input, " "); 338 | while (it.next()) |part| { 339 | if (part.len == 0) continue; 340 | try argv.append(part); 341 | } 342 | 343 | const result = try std.ChildProcess.exec(.{ 344 | .allocator = allocator, 345 | .argv = argv.items, 346 | }); 347 | if (print_output) { 348 | std.debug.print("{s} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n", .{input}); 349 | std.debug.print("\n{s}", .{result.stdout}); 350 | std.debug.print("\n{s}", .{result.stderr}); 351 | std.debug.print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<\n\n", .{}); 352 | } 353 | return result; 354 | } 355 | 356 | const europe_stockholm_transitions_json = 357 | \\ [ 358 | \\ { "ts": 1667091600, "offset": 3600 }, 359 | \\ { "ts": 1679792400, "offset": 7200 }, 360 | \\ { "ts": 1698541200, "offset": 3600 }, 361 | \\ { "ts": 1711846800, "offset": 7200 }, 362 | \\ { "ts": 1729990800, "offset": 3600 }, 363 | \\ { "ts": 1743296400, "offset": 7200 }, 364 | \\ { "ts": 1761440400, "offset": 3600 }, 365 | \\ { "ts": 1774746000, "offset": 7200 }, 366 | \\ { "ts": 1792890000, "offset": 3600 }, 367 | \\ { "ts": 1806195600, "offset": 7200 }, 368 | \\ { "ts": 1824944400, "offset": 3600 }, 369 | \\ { "ts": 1837645200, "offset": 7200 }, 370 | \\ { "ts": 1856394000, "offset": 3600 }, 371 | \\ { "ts": 1869094800, "offset": 7200 }, 372 | \\ { "ts": 1887843600, "offset": 3600 }, 373 | \\ { "ts": 1901149200, "offset": 7200 }, 374 | \\ { "ts": 1919293200, "offset": 3600 }, 375 | \\ { "ts": 1932598800, "offset": 7200 }, 376 | \\ { "ts": 1950742800, "offset": 3600 }, 377 | \\ { "ts": 1964048400, "offset": 7200 }, 378 | \\ { "ts": 1982797200, "offset": 3600 }, 379 | \\ { "ts": 1995498000, "offset": 7200 }, 380 | \\ { "ts": 2014246800, "offset": 3600 }, 381 | \\ { "ts": 2026947600, "offset": 7200 }, 382 | \\ { "ts": 2045696400, "offset": 3600 }, 383 | \\ { "ts": 2058397200, "offset": 7200 }, 384 | \\ { "ts": 2077146000, "offset": 3600 }, 385 | \\ { "ts": 2090451600, "offset": 7200 }, 386 | \\ { "ts": 2108595600, "offset": 3600 }, 387 | \\ { "ts": 2121901200, "offset": 7200 }, 388 | \\ { "ts": 2140045200, "offset": 3600 } 389 | \\ ] 390 | ; 391 | 392 | const DateTime = struct { 393 | date_str: []const u8, 394 | timestamp: i64, 395 | }; 396 | 397 | // Good 'ol Java 398 | // public static void main(String[] args) { 399 | // var start = ZonedDateTime.of(LocalDate.of(2023, 1, 1), LocalTime.MIDNIGHT, ZoneId.of("Europe/Stockholm")); 400 | // while (start.getYear() == 2023) { 401 | // var dateStr = start.format(DateTimeFormatter.ofPattern("yyyyMMdd")); 402 | // var epoch = start.toInstant().toEpochMilli() / 1000; 403 | // System.out.format(".{ .date_str = \"%s%02d%02d\", .timestamp = %s },\n", start.getYear(), start.getMonthValue(), start.getDayOfMonth(), epoch); 404 | // start = start.plusDays(1); 405 | // } 406 | // } 407 | const year_2023 = [_]DateTime{ 408 | .{ .date_str = "20230101", .timestamp = 1672527600 }, 409 | .{ .date_str = "20230102", .timestamp = 1672614000 }, 410 | .{ .date_str = "20230103", .timestamp = 1672700400 }, 411 | .{ .date_str = "20230104", .timestamp = 1672786800 }, 412 | .{ .date_str = "20230105", .timestamp = 1672873200 }, 413 | .{ .date_str = "20230106", .timestamp = 1672959600 }, 414 | .{ .date_str = "20230107", .timestamp = 1673046000 }, 415 | .{ .date_str = "20230108", .timestamp = 1673132400 }, 416 | .{ .date_str = "20230109", .timestamp = 1673218800 }, 417 | .{ .date_str = "20230110", .timestamp = 1673305200 }, 418 | .{ .date_str = "20230111", .timestamp = 1673391600 }, 419 | .{ .date_str = "20230112", .timestamp = 1673478000 }, 420 | .{ .date_str = "20230113", .timestamp = 1673564400 }, 421 | .{ .date_str = "20230114", .timestamp = 1673650800 }, 422 | .{ .date_str = "20230115", .timestamp = 1673737200 }, 423 | .{ .date_str = "20230116", .timestamp = 1673823600 }, 424 | .{ .date_str = "20230117", .timestamp = 1673910000 }, 425 | .{ .date_str = "20230118", .timestamp = 1673996400 }, 426 | .{ .date_str = "20230119", .timestamp = 1674082800 }, 427 | .{ .date_str = "20230120", .timestamp = 1674169200 }, 428 | .{ .date_str = "20230121", .timestamp = 1674255600 }, 429 | .{ .date_str = "20230122", .timestamp = 1674342000 }, 430 | .{ .date_str = "20230123", .timestamp = 1674428400 }, 431 | .{ .date_str = "20230124", .timestamp = 1674514800 }, 432 | .{ .date_str = "20230125", .timestamp = 1674601200 }, 433 | .{ .date_str = "20230126", .timestamp = 1674687600 }, 434 | .{ .date_str = "20230127", .timestamp = 1674774000 }, 435 | .{ .date_str = "20230128", .timestamp = 1674860400 }, 436 | .{ .date_str = "20230129", .timestamp = 1674946800 }, 437 | .{ .date_str = "20230130", .timestamp = 1675033200 }, 438 | .{ .date_str = "20230131", .timestamp = 1675119600 }, 439 | .{ .date_str = "20230201", .timestamp = 1675206000 }, 440 | .{ .date_str = "20230202", .timestamp = 1675292400 }, 441 | .{ .date_str = "20230203", .timestamp = 1675378800 }, 442 | .{ .date_str = "20230204", .timestamp = 1675465200 }, 443 | .{ .date_str = "20230205", .timestamp = 1675551600 }, 444 | .{ .date_str = "20230206", .timestamp = 1675638000 }, 445 | .{ .date_str = "20230207", .timestamp = 1675724400 }, 446 | .{ .date_str = "20230208", .timestamp = 1675810800 }, 447 | .{ .date_str = "20230209", .timestamp = 1675897200 }, 448 | .{ .date_str = "20230210", .timestamp = 1675983600 }, 449 | .{ .date_str = "20230211", .timestamp = 1676070000 }, 450 | .{ .date_str = "20230212", .timestamp = 1676156400 }, 451 | .{ .date_str = "20230213", .timestamp = 1676242800 }, 452 | .{ .date_str = "20230214", .timestamp = 1676329200 }, 453 | .{ .date_str = "20230215", .timestamp = 1676415600 }, 454 | .{ .date_str = "20230216", .timestamp = 1676502000 }, 455 | .{ .date_str = "20230217", .timestamp = 1676588400 }, 456 | .{ .date_str = "20230218", .timestamp = 1676674800 }, 457 | .{ .date_str = "20230219", .timestamp = 1676761200 }, 458 | .{ .date_str = "20230220", .timestamp = 1676847600 }, 459 | .{ .date_str = "20230221", .timestamp = 1676934000 }, 460 | .{ .date_str = "20230222", .timestamp = 1677020400 }, 461 | .{ .date_str = "20230223", .timestamp = 1677106800 }, 462 | .{ .date_str = "20230224", .timestamp = 1677193200 }, 463 | .{ .date_str = "20230225", .timestamp = 1677279600 }, 464 | .{ .date_str = "20230226", .timestamp = 1677366000 }, 465 | .{ .date_str = "20230227", .timestamp = 1677452400 }, 466 | .{ .date_str = "20230228", .timestamp = 1677538800 }, 467 | .{ .date_str = "20230301", .timestamp = 1677625200 }, 468 | .{ .date_str = "20230302", .timestamp = 1677711600 }, 469 | .{ .date_str = "20230303", .timestamp = 1677798000 }, 470 | .{ .date_str = "20230304", .timestamp = 1677884400 }, 471 | .{ .date_str = "20230305", .timestamp = 1677970800 }, 472 | .{ .date_str = "20230306", .timestamp = 1678057200 }, 473 | .{ .date_str = "20230307", .timestamp = 1678143600 }, 474 | .{ .date_str = "20230308", .timestamp = 1678230000 }, 475 | .{ .date_str = "20230309", .timestamp = 1678316400 }, 476 | .{ .date_str = "20230310", .timestamp = 1678402800 }, 477 | .{ .date_str = "20230311", .timestamp = 1678489200 }, 478 | .{ .date_str = "20230312", .timestamp = 1678575600 }, 479 | .{ .date_str = "20230313", .timestamp = 1678662000 }, 480 | .{ .date_str = "20230314", .timestamp = 1678748400 }, 481 | .{ .date_str = "20230315", .timestamp = 1678834800 }, 482 | .{ .date_str = "20230316", .timestamp = 1678921200 }, 483 | .{ .date_str = "20230317", .timestamp = 1679007600 }, 484 | .{ .date_str = "20230318", .timestamp = 1679094000 }, 485 | .{ .date_str = "20230319", .timestamp = 1679180400 }, 486 | .{ .date_str = "20230320", .timestamp = 1679266800 }, 487 | .{ .date_str = "20230321", .timestamp = 1679353200 }, 488 | .{ .date_str = "20230322", .timestamp = 1679439600 }, 489 | .{ .date_str = "20230323", .timestamp = 1679526000 }, 490 | .{ .date_str = "20230324", .timestamp = 1679612400 }, 491 | .{ .date_str = "20230325", .timestamp = 1679698800 }, 492 | .{ .date_str = "20230326", .timestamp = 1679785200 }, 493 | .{ .date_str = "20230327", .timestamp = 1679868000 }, 494 | .{ .date_str = "20230328", .timestamp = 1679954400 }, 495 | .{ .date_str = "20230329", .timestamp = 1680040800 }, 496 | .{ .date_str = "20230330", .timestamp = 1680127200 }, 497 | .{ .date_str = "20230331", .timestamp = 1680213600 }, 498 | .{ .date_str = "20230401", .timestamp = 1680300000 }, 499 | .{ .date_str = "20230402", .timestamp = 1680386400 }, 500 | .{ .date_str = "20230403", .timestamp = 1680472800 }, 501 | .{ .date_str = "20230404", .timestamp = 1680559200 }, 502 | .{ .date_str = "20230405", .timestamp = 1680645600 }, 503 | .{ .date_str = "20230406", .timestamp = 1680732000 }, 504 | .{ .date_str = "20230407", .timestamp = 1680818400 }, 505 | .{ .date_str = "20230408", .timestamp = 1680904800 }, 506 | .{ .date_str = "20230409", .timestamp = 1680991200 }, 507 | .{ .date_str = "20230410", .timestamp = 1681077600 }, 508 | .{ .date_str = "20230411", .timestamp = 1681164000 }, 509 | .{ .date_str = "20230412", .timestamp = 1681250400 }, 510 | .{ .date_str = "20230413", .timestamp = 1681336800 }, 511 | .{ .date_str = "20230414", .timestamp = 1681423200 }, 512 | .{ .date_str = "20230415", .timestamp = 1681509600 }, 513 | .{ .date_str = "20230416", .timestamp = 1681596000 }, 514 | .{ .date_str = "20230417", .timestamp = 1681682400 }, 515 | .{ .date_str = "20230418", .timestamp = 1681768800 }, 516 | .{ .date_str = "20230419", .timestamp = 1681855200 }, 517 | .{ .date_str = "20230420", .timestamp = 1681941600 }, 518 | .{ .date_str = "20230421", .timestamp = 1682028000 }, 519 | .{ .date_str = "20230422", .timestamp = 1682114400 }, 520 | .{ .date_str = "20230423", .timestamp = 1682200800 }, 521 | .{ .date_str = "20230424", .timestamp = 1682287200 }, 522 | .{ .date_str = "20230425", .timestamp = 1682373600 }, 523 | .{ .date_str = "20230426", .timestamp = 1682460000 }, 524 | .{ .date_str = "20230427", .timestamp = 1682546400 }, 525 | .{ .date_str = "20230428", .timestamp = 1682632800 }, 526 | .{ .date_str = "20230429", .timestamp = 1682719200 }, 527 | .{ .date_str = "20230430", .timestamp = 1682805600 }, 528 | .{ .date_str = "20230501", .timestamp = 1682892000 }, 529 | .{ .date_str = "20230502", .timestamp = 1682978400 }, 530 | .{ .date_str = "20230503", .timestamp = 1683064800 }, 531 | .{ .date_str = "20230504", .timestamp = 1683151200 }, 532 | .{ .date_str = "20230505", .timestamp = 1683237600 }, 533 | .{ .date_str = "20230506", .timestamp = 1683324000 }, 534 | .{ .date_str = "20230507", .timestamp = 1683410400 }, 535 | .{ .date_str = "20230508", .timestamp = 1683496800 }, 536 | .{ .date_str = "20230509", .timestamp = 1683583200 }, 537 | .{ .date_str = "20230510", .timestamp = 1683669600 }, 538 | .{ .date_str = "20230511", .timestamp = 1683756000 }, 539 | .{ .date_str = "20230512", .timestamp = 1683842400 }, 540 | .{ .date_str = "20230513", .timestamp = 1683928800 }, 541 | .{ .date_str = "20230514", .timestamp = 1684015200 }, 542 | .{ .date_str = "20230515", .timestamp = 1684101600 }, 543 | .{ .date_str = "20230516", .timestamp = 1684188000 }, 544 | .{ .date_str = "20230517", .timestamp = 1684274400 }, 545 | .{ .date_str = "20230518", .timestamp = 1684360800 }, 546 | .{ .date_str = "20230519", .timestamp = 1684447200 }, 547 | .{ .date_str = "20230520", .timestamp = 1684533600 }, 548 | .{ .date_str = "20230521", .timestamp = 1684620000 }, 549 | .{ .date_str = "20230522", .timestamp = 1684706400 }, 550 | .{ .date_str = "20230523", .timestamp = 1684792800 }, 551 | .{ .date_str = "20230524", .timestamp = 1684879200 }, 552 | .{ .date_str = "20230525", .timestamp = 1684965600 }, 553 | .{ .date_str = "20230526", .timestamp = 1685052000 }, 554 | .{ .date_str = "20230527", .timestamp = 1685138400 }, 555 | .{ .date_str = "20230528", .timestamp = 1685224800 }, 556 | .{ .date_str = "20230529", .timestamp = 1685311200 }, 557 | .{ .date_str = "20230530", .timestamp = 1685397600 }, 558 | .{ .date_str = "20230531", .timestamp = 1685484000 }, 559 | .{ .date_str = "20230601", .timestamp = 1685570400 }, 560 | .{ .date_str = "20230602", .timestamp = 1685656800 }, 561 | .{ .date_str = "20230603", .timestamp = 1685743200 }, 562 | .{ .date_str = "20230604", .timestamp = 1685829600 }, 563 | .{ .date_str = "20230605", .timestamp = 1685916000 }, 564 | .{ .date_str = "20230606", .timestamp = 1686002400 }, 565 | .{ .date_str = "20230607", .timestamp = 1686088800 }, 566 | .{ .date_str = "20230608", .timestamp = 1686175200 }, 567 | .{ .date_str = "20230609", .timestamp = 1686261600 }, 568 | .{ .date_str = "20230610", .timestamp = 1686348000 }, 569 | .{ .date_str = "20230611", .timestamp = 1686434400 }, 570 | .{ .date_str = "20230612", .timestamp = 1686520800 }, 571 | .{ .date_str = "20230613", .timestamp = 1686607200 }, 572 | .{ .date_str = "20230614", .timestamp = 1686693600 }, 573 | .{ .date_str = "20230615", .timestamp = 1686780000 }, 574 | .{ .date_str = "20230616", .timestamp = 1686866400 }, 575 | .{ .date_str = "20230617", .timestamp = 1686952800 }, 576 | .{ .date_str = "20230618", .timestamp = 1687039200 }, 577 | .{ .date_str = "20230619", .timestamp = 1687125600 }, 578 | .{ .date_str = "20230620", .timestamp = 1687212000 }, 579 | .{ .date_str = "20230621", .timestamp = 1687298400 }, 580 | .{ .date_str = "20230622", .timestamp = 1687384800 }, 581 | .{ .date_str = "20230623", .timestamp = 1687471200 }, 582 | .{ .date_str = "20230624", .timestamp = 1687557600 }, 583 | .{ .date_str = "20230625", .timestamp = 1687644000 }, 584 | .{ .date_str = "20230626", .timestamp = 1687730400 }, 585 | .{ .date_str = "20230627", .timestamp = 1687816800 }, 586 | .{ .date_str = "20230628", .timestamp = 1687903200 }, 587 | .{ .date_str = "20230629", .timestamp = 1687989600 }, 588 | .{ .date_str = "20230630", .timestamp = 1688076000 }, 589 | .{ .date_str = "20230701", .timestamp = 1688162400 }, 590 | .{ .date_str = "20230702", .timestamp = 1688248800 }, 591 | .{ .date_str = "20230703", .timestamp = 1688335200 }, 592 | .{ .date_str = "20230704", .timestamp = 1688421600 }, 593 | .{ .date_str = "20230705", .timestamp = 1688508000 }, 594 | .{ .date_str = "20230706", .timestamp = 1688594400 }, 595 | .{ .date_str = "20230707", .timestamp = 1688680800 }, 596 | .{ .date_str = "20230708", .timestamp = 1688767200 }, 597 | .{ .date_str = "20230709", .timestamp = 1688853600 }, 598 | .{ .date_str = "20230710", .timestamp = 1688940000 }, 599 | .{ .date_str = "20230711", .timestamp = 1689026400 }, 600 | .{ .date_str = "20230712", .timestamp = 1689112800 }, 601 | .{ .date_str = "20230713", .timestamp = 1689199200 }, 602 | .{ .date_str = "20230714", .timestamp = 1689285600 }, 603 | .{ .date_str = "20230715", .timestamp = 1689372000 }, 604 | .{ .date_str = "20230716", .timestamp = 1689458400 }, 605 | .{ .date_str = "20230717", .timestamp = 1689544800 }, 606 | .{ .date_str = "20230718", .timestamp = 1689631200 }, 607 | .{ .date_str = "20230719", .timestamp = 1689717600 }, 608 | .{ .date_str = "20230720", .timestamp = 1689804000 }, 609 | .{ .date_str = "20230721", .timestamp = 1689890400 }, 610 | .{ .date_str = "20230722", .timestamp = 1689976800 }, 611 | .{ .date_str = "20230723", .timestamp = 1690063200 }, 612 | .{ .date_str = "20230724", .timestamp = 1690149600 }, 613 | .{ .date_str = "20230725", .timestamp = 1690236000 }, 614 | .{ .date_str = "20230726", .timestamp = 1690322400 }, 615 | .{ .date_str = "20230727", .timestamp = 1690408800 }, 616 | .{ .date_str = "20230728", .timestamp = 1690495200 }, 617 | .{ .date_str = "20230729", .timestamp = 1690581600 }, 618 | .{ .date_str = "20230730", .timestamp = 1690668000 }, 619 | .{ .date_str = "20230731", .timestamp = 1690754400 }, 620 | .{ .date_str = "20230801", .timestamp = 1690840800 }, 621 | .{ .date_str = "20230802", .timestamp = 1690927200 }, 622 | .{ .date_str = "20230803", .timestamp = 1691013600 }, 623 | .{ .date_str = "20230804", .timestamp = 1691100000 }, 624 | .{ .date_str = "20230805", .timestamp = 1691186400 }, 625 | .{ .date_str = "20230806", .timestamp = 1691272800 }, 626 | .{ .date_str = "20230807", .timestamp = 1691359200 }, 627 | .{ .date_str = "20230808", .timestamp = 1691445600 }, 628 | .{ .date_str = "20230809", .timestamp = 1691532000 }, 629 | .{ .date_str = "20230810", .timestamp = 1691618400 }, 630 | .{ .date_str = "20230811", .timestamp = 1691704800 }, 631 | .{ .date_str = "20230812", .timestamp = 1691791200 }, 632 | .{ .date_str = "20230813", .timestamp = 1691877600 }, 633 | .{ .date_str = "20230814", .timestamp = 1691964000 }, 634 | .{ .date_str = "20230815", .timestamp = 1692050400 }, 635 | .{ .date_str = "20230816", .timestamp = 1692136800 }, 636 | .{ .date_str = "20230817", .timestamp = 1692223200 }, 637 | .{ .date_str = "20230818", .timestamp = 1692309600 }, 638 | .{ .date_str = "20230819", .timestamp = 1692396000 }, 639 | .{ .date_str = "20230820", .timestamp = 1692482400 }, 640 | .{ .date_str = "20230821", .timestamp = 1692568800 }, 641 | .{ .date_str = "20230822", .timestamp = 1692655200 }, 642 | .{ .date_str = "20230823", .timestamp = 1692741600 }, 643 | .{ .date_str = "20230824", .timestamp = 1692828000 }, 644 | .{ .date_str = "20230825", .timestamp = 1692914400 }, 645 | .{ .date_str = "20230826", .timestamp = 1693000800 }, 646 | .{ .date_str = "20230827", .timestamp = 1693087200 }, 647 | .{ .date_str = "20230828", .timestamp = 1693173600 }, 648 | .{ .date_str = "20230829", .timestamp = 1693260000 }, 649 | .{ .date_str = "20230830", .timestamp = 1693346400 }, 650 | .{ .date_str = "20230831", .timestamp = 1693432800 }, 651 | .{ .date_str = "20230901", .timestamp = 1693519200 }, 652 | .{ .date_str = "20230902", .timestamp = 1693605600 }, 653 | .{ .date_str = "20230903", .timestamp = 1693692000 }, 654 | .{ .date_str = "20230904", .timestamp = 1693778400 }, 655 | .{ .date_str = "20230905", .timestamp = 1693864800 }, 656 | .{ .date_str = "20230906", .timestamp = 1693951200 }, 657 | .{ .date_str = "20230907", .timestamp = 1694037600 }, 658 | .{ .date_str = "20230908", .timestamp = 1694124000 }, 659 | .{ .date_str = "20230909", .timestamp = 1694210400 }, 660 | .{ .date_str = "20230910", .timestamp = 1694296800 }, 661 | .{ .date_str = "20230911", .timestamp = 1694383200 }, 662 | .{ .date_str = "20230912", .timestamp = 1694469600 }, 663 | .{ .date_str = "20230913", .timestamp = 1694556000 }, 664 | .{ .date_str = "20230914", .timestamp = 1694642400 }, 665 | .{ .date_str = "20230915", .timestamp = 1694728800 }, 666 | .{ .date_str = "20230916", .timestamp = 1694815200 }, 667 | .{ .date_str = "20230917", .timestamp = 1694901600 }, 668 | .{ .date_str = "20230918", .timestamp = 1694988000 }, 669 | .{ .date_str = "20230919", .timestamp = 1695074400 }, 670 | .{ .date_str = "20230920", .timestamp = 1695160800 }, 671 | .{ .date_str = "20230921", .timestamp = 1695247200 }, 672 | .{ .date_str = "20230922", .timestamp = 1695333600 }, 673 | .{ .date_str = "20230923", .timestamp = 1695420000 }, 674 | .{ .date_str = "20230924", .timestamp = 1695506400 }, 675 | .{ .date_str = "20230925", .timestamp = 1695592800 }, 676 | .{ .date_str = "20230926", .timestamp = 1695679200 }, 677 | .{ .date_str = "20230927", .timestamp = 1695765600 }, 678 | .{ .date_str = "20230928", .timestamp = 1695852000 }, 679 | .{ .date_str = "20230929", .timestamp = 1695938400 }, 680 | .{ .date_str = "20230930", .timestamp = 1696024800 }, 681 | .{ .date_str = "20231001", .timestamp = 1696111200 }, 682 | .{ .date_str = "20231002", .timestamp = 1696197600 }, 683 | .{ .date_str = "20231003", .timestamp = 1696284000 }, 684 | .{ .date_str = "20231004", .timestamp = 1696370400 }, 685 | .{ .date_str = "20231005", .timestamp = 1696456800 }, 686 | .{ .date_str = "20231006", .timestamp = 1696543200 }, 687 | .{ .date_str = "20231007", .timestamp = 1696629600 }, 688 | .{ .date_str = "20231008", .timestamp = 1696716000 }, 689 | .{ .date_str = "20231009", .timestamp = 1696802400 }, 690 | .{ .date_str = "20231010", .timestamp = 1696888800 }, 691 | .{ .date_str = "20231011", .timestamp = 1696975200 }, 692 | .{ .date_str = "20231012", .timestamp = 1697061600 }, 693 | .{ .date_str = "20231013", .timestamp = 1697148000 }, 694 | .{ .date_str = "20231014", .timestamp = 1697234400 }, 695 | .{ .date_str = "20231015", .timestamp = 1697320800 }, 696 | .{ .date_str = "20231016", .timestamp = 1697407200 }, 697 | .{ .date_str = "20231017", .timestamp = 1697493600 }, 698 | .{ .date_str = "20231018", .timestamp = 1697580000 }, 699 | .{ .date_str = "20231019", .timestamp = 1697666400 }, 700 | .{ .date_str = "20231020", .timestamp = 1697752800 }, 701 | .{ .date_str = "20231021", .timestamp = 1697839200 }, 702 | .{ .date_str = "20231022", .timestamp = 1697925600 }, 703 | .{ .date_str = "20231023", .timestamp = 1698012000 }, 704 | .{ .date_str = "20231024", .timestamp = 1698098400 }, 705 | .{ .date_str = "20231025", .timestamp = 1698184800 }, 706 | .{ .date_str = "20231026", .timestamp = 1698271200 }, 707 | .{ .date_str = "20231027", .timestamp = 1698357600 }, 708 | .{ .date_str = "20231028", .timestamp = 1698444000 }, 709 | .{ .date_str = "20231029", .timestamp = 1698530400 }, 710 | .{ .date_str = "20231030", .timestamp = 1698620400 }, 711 | .{ .date_str = "20231031", .timestamp = 1698706800 }, 712 | .{ .date_str = "20231101", .timestamp = 1698793200 }, 713 | .{ .date_str = "20231102", .timestamp = 1698879600 }, 714 | .{ .date_str = "20231103", .timestamp = 1698966000 }, 715 | .{ .date_str = "20231104", .timestamp = 1699052400 }, 716 | .{ .date_str = "20231105", .timestamp = 1699138800 }, 717 | .{ .date_str = "20231106", .timestamp = 1699225200 }, 718 | .{ .date_str = "20231107", .timestamp = 1699311600 }, 719 | .{ .date_str = "20231108", .timestamp = 1699398000 }, 720 | .{ .date_str = "20231109", .timestamp = 1699484400 }, 721 | .{ .date_str = "20231110", .timestamp = 1699570800 }, 722 | .{ .date_str = "20231111", .timestamp = 1699657200 }, 723 | .{ .date_str = "20231112", .timestamp = 1699743600 }, 724 | .{ .date_str = "20231113", .timestamp = 1699830000 }, 725 | .{ .date_str = "20231114", .timestamp = 1699916400 }, 726 | .{ .date_str = "20231115", .timestamp = 1700002800 }, 727 | .{ .date_str = "20231116", .timestamp = 1700089200 }, 728 | .{ .date_str = "20231117", .timestamp = 1700175600 }, 729 | .{ .date_str = "20231118", .timestamp = 1700262000 }, 730 | .{ .date_str = "20231119", .timestamp = 1700348400 }, 731 | .{ .date_str = "20231120", .timestamp = 1700434800 }, 732 | .{ .date_str = "20231121", .timestamp = 1700521200 }, 733 | .{ .date_str = "20231122", .timestamp = 1700607600 }, 734 | .{ .date_str = "20231123", .timestamp = 1700694000 }, 735 | .{ .date_str = "20231124", .timestamp = 1700780400 }, 736 | .{ .date_str = "20231125", .timestamp = 1700866800 }, 737 | .{ .date_str = "20231126", .timestamp = 1700953200 }, 738 | .{ .date_str = "20231127", .timestamp = 1701039600 }, 739 | .{ .date_str = "20231128", .timestamp = 1701126000 }, 740 | .{ .date_str = "20231129", .timestamp = 1701212400 }, 741 | .{ .date_str = "20231130", .timestamp = 1701298800 }, 742 | .{ .date_str = "20231201", .timestamp = 1701385200 }, 743 | .{ .date_str = "20231202", .timestamp = 1701471600 }, 744 | .{ .date_str = "20231203", .timestamp = 1701558000 }, 745 | .{ .date_str = "20231204", .timestamp = 1701644400 }, 746 | .{ .date_str = "20231205", .timestamp = 1701730800 }, 747 | .{ .date_str = "20231206", .timestamp = 1701817200 }, 748 | .{ .date_str = "20231207", .timestamp = 1701903600 }, 749 | .{ .date_str = "20231208", .timestamp = 1701990000 }, 750 | .{ .date_str = "20231209", .timestamp = 1702076400 }, 751 | .{ .date_str = "20231210", .timestamp = 1702162800 }, 752 | .{ .date_str = "20231211", .timestamp = 1702249200 }, 753 | .{ .date_str = "20231212", .timestamp = 1702335600 }, 754 | .{ .date_str = "20231213", .timestamp = 1702422000 }, 755 | .{ .date_str = "20231214", .timestamp = 1702508400 }, 756 | .{ .date_str = "20231215", .timestamp = 1702594800 }, 757 | .{ .date_str = "20231216", .timestamp = 1702681200 }, 758 | .{ .date_str = "20231217", .timestamp = 1702767600 }, 759 | .{ .date_str = "20231218", .timestamp = 1702854000 }, 760 | .{ .date_str = "20231219", .timestamp = 1702940400 }, 761 | .{ .date_str = "20231220", .timestamp = 1703026800 }, 762 | .{ .date_str = "20231221", .timestamp = 1703113200 }, 763 | .{ .date_str = "20231222", .timestamp = 1703199600 }, 764 | .{ .date_str = "20231223", .timestamp = 1703286000 }, 765 | .{ .date_str = "20231224", .timestamp = 1703372400 }, 766 | .{ .date_str = "20231225", .timestamp = 1703458800 }, 767 | .{ .date_str = "20231226", .timestamp = 1703545200 }, 768 | .{ .date_str = "20231227", .timestamp = 1703631600 }, 769 | .{ .date_str = "20231228", .timestamp = 1703718000 }, 770 | .{ .date_str = "20231229", .timestamp = 1703804400 }, 771 | .{ .date_str = "20231230", .timestamp = 1703890800 }, 772 | .{ .date_str = "20231231", .timestamp = 1703977200 }, 773 | }; 774 | -------------------------------------------------------------------------------- /src/tui.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const color = @import("color.zig"); 3 | const date = @import("date.zig"); 4 | const main = @import("main.zig"); 5 | 6 | const LocalDate = date.LocalDate; 7 | const Rgb = color.Rgb; 8 | const Stats = main.Stats; 9 | 10 | const UBOX = "\u{2588}"; 11 | const UDASHLONG = "\u{2504}"; 12 | const UDOT = "\u{30FB}"; 13 | const ULINE = "\u{2501}"; 14 | const UMESH = "\u{2591}"; 15 | 16 | const default_margin: usize = 1; 17 | 18 | fn startColor(w: anytype, rgb: Rgb) void { 19 | w.print("\x1b[38;2;{d};{d};{d}m", .{ rgb.r, rgb.g, rgb.b }) catch unreachable; 20 | } 21 | 22 | fn endColor(w: anytype) void { 23 | w.writeAll("\x1b[0m") catch unreachable; 24 | } 25 | 26 | const Glyph = enum { 27 | box, 28 | mesh, 29 | dash, 30 | line, 31 | space, 32 | }; 33 | 34 | fn drawGlyph(w: anytype, glyph: Glyph, rgb: ?Rgb) void { 35 | if (rgb) |c| { 36 | startColor(w, c); 37 | } 38 | const str = switch (glyph) { 39 | .box => UBOX, 40 | .mesh => UMESH, 41 | .dash => UDASHLONG, 42 | .line => ULINE, 43 | .space => " ", 44 | }; 45 | w.writeAll(str) catch unreachable; 46 | w.writeAll(str) catch unreachable; 47 | if (rgb) |_| { 48 | endColor(w); 49 | } 50 | } 51 | 52 | fn drawGlyphs(w: anytype, glyph: Glyph, rgb: Rgb, n: usize) void { 53 | var i: usize = 0; 54 | while (i < n) : (i += 1) { 55 | drawGlyph(w, glyph, rgb); 56 | } 57 | } 58 | 59 | fn indent(w: anytype, n: usize) void { 60 | var i: usize = 0; 61 | while (i < n) : (i += 1) { 62 | w.writeByte(' ') catch unreachable; 63 | } 64 | } 65 | 66 | fn verticalSpace(w: anytype, n: usize) void { 67 | var i: usize = 0; 68 | while (i < n) : (i += 1) { 69 | w.writeByte('\n') catch unreachable; 70 | } 71 | indent(w, default_margin); 72 | } 73 | 74 | pub fn drawHeader(w: anytype, ctx: *const DrawContext) !void { 75 | var month: ?usize = null; 76 | var day = ctx.start; 77 | 78 | var day_buf = std.mem.zeroes([256]u8); 79 | var day_fba = std.io.fixedBufferStream(&day_buf); 80 | var dw = day_fba.writer(); 81 | 82 | var weekday_buf = std.mem.zeroes([256]u8); 83 | var weekday_fba = std.io.fixedBufferStream(&weekday_buf); 84 | var wdw = weekday_fba.writer(); 85 | 86 | indent(w, ctx.max_prelude_len + default_margin); 87 | while (day.compare(ctx.end) != .gt) : (day = day.next()) { 88 | if (month == null or day.month != month) { 89 | month = day.month; 90 | try w.print("{d:<4}", .{month.?}); 91 | } else { 92 | try w.writeAll(" "); 93 | } 94 | try dw.print("{d:<4}", .{day.day}); 95 | try wdw.print("{s:<4}", .{@tagName(date.getWeekdayFromEpoch(day.toEpoch()))}); 96 | } 97 | 98 | try w.writeAll("\n"); 99 | indent(w, ctx.max_prelude_len + default_margin); 100 | try w.writeAll(day_fba.getWritten()); 101 | 102 | try w.writeAll("\n"); 103 | indent(w, ctx.max_prelude_len + default_margin); 104 | try w.writeAll(weekday_fba.getWritten()); 105 | } 106 | 107 | fn formatPrelude(index: usize, chain: *const main.Chain) []const u8 { 108 | return main.scratchPrint("({d}{s}) {s}", .{ index + 1, if (chain.isActive()) "" else "/s", chain.name[0..chain.name_len] }); 109 | } 110 | 111 | fn drawChain(comptime kind: main.Kind, w: anytype, ctx: *DrawContext, chain: *const main.Chain, links: []const main.Link, index: usize) !void { 112 | if (ctx.max_prelude_len > 0) { 113 | const prelude = formatPrelude(index, chain); 114 | const padding = ctx.max_prelude_len - prelude.len; 115 | try w.writeAll(prelude); 116 | var i: usize = 0; 117 | while (i < padding) : (i += 1) { 118 | try w.writeByte(' '); 119 | } 120 | } 121 | 122 | var i: usize = 0; 123 | const items = while (i < links.len) : (i += 1) { 124 | if (links[i].toLocalDate().compare(ctx.start) != .lt) 125 | break links[i..]; 126 | } else return; 127 | 128 | const week_info: struct { link_count: [date.max_weeks_per_year + 1]u8, index: usize } = if (kind == .weekly) blk: { 129 | const start_of_week = ctx.start.atStartOfWeek(); 130 | var j: usize = 0; 131 | const items_full_week = while (j < links.len) : (j += 1) { 132 | if (links[j].toLocalDate().compare(start_of_week) != .lt) 133 | break links[j..]; 134 | } else unreachable; 135 | 136 | var weeks = std.mem.zeroes([date.max_weeks_per_year + 1]u8); 137 | for (items_full_week) |item| { 138 | const week = date.getWeekNumberFromEpoch(item.localAtStartOfDay()); 139 | weeks[week] += 1; 140 | } 141 | break :blk .{ .link_count = weeks, .index = j }; 142 | } else undefined; 143 | 144 | var items_index: usize = 0; 145 | var day = ctx.start; 146 | var seen_so_far: usize = if (kind == .weekly) i - week_info.index else 0; 147 | while (day.compare(ctx.end) != .gt) { 148 | const end = items_index == items.len - 1; 149 | const link = items[items_index]; 150 | if (day.compare(ctx.end) == .gt) break; 151 | const linked_on_day = link.toLocalDate().compare(day) == .eq; 152 | const next_day = day.next(); 153 | defer day = next_day; 154 | 155 | const link_color = if (link.tags != 0) 156 | chain.tagColor(link.tags) 157 | else 158 | chain.color; 159 | switch (kind) { 160 | .daily => { 161 | const next = if (end) false else items[items_index + 1].toLocalDate().compare(next_day) == .eq; 162 | 163 | if (linked_on_day) { 164 | drawGlyph(w, .box, link_color); 165 | if (next) { 166 | drawGlyph(w, .line, color.sand); 167 | } else { 168 | if (!end) 169 | drawGlyph(w, .space, null); 170 | } 171 | items_index += 1; 172 | if (items_index == items.len) 173 | break; 174 | } else { 175 | drawGlyph(w, .space, null); 176 | drawGlyph(w, .space, null); 177 | } 178 | }, 179 | .weekly => { 180 | const week = @as(u8, @intCast(date.getWeekNumberFromEpoch(day.toEpoch()))); 181 | const next_week = (week + 1) % date.max_weeks_per_year; 182 | const prev_week = (week + date.max_weeks_per_year - 1) % date.max_weeks_per_year; 183 | 184 | const this_week_count = week_info.link_count[week]; 185 | const this_week_linked = this_week_count >= chain.min_days; 186 | const next_week_linked = week_info.link_count[next_week] >= chain.min_days; 187 | const prev_week_linked = week_info.link_count[prev_week] >= chain.min_days; 188 | 189 | if (linked_on_day) 190 | seen_so_far += 1; 191 | 192 | const should_draw_link = this_week_linked and 193 | ((prev_week_linked and seen_so_far == 0) or 194 | (seen_so_far > 0 and seen_so_far < this_week_count) or 195 | (next_week_linked and seen_so_far >= this_week_count)); 196 | 197 | if (linked_on_day) { 198 | drawGlyph(w, .box, link_color); 199 | 200 | if (should_draw_link) { 201 | drawGlyph(w, .line, color.sand); 202 | } else { 203 | drawGlyph(w, .space, null); 204 | } 205 | 206 | items_index += 1; 207 | if (items_index == items.len) 208 | break; 209 | } else { 210 | if (should_draw_link) { 211 | drawGlyph(w, .line, color.sand); 212 | drawGlyph(w, .line, color.sand); 213 | } else { 214 | drawGlyph(w, .space, null); 215 | drawGlyph(w, .space, null); 216 | } 217 | } 218 | if (date.getWeekdayFromEpoch(day.toEpoch()) == .sun) { 219 | seen_so_far = 0; 220 | } 221 | }, 222 | } 223 | } 224 | } 225 | 226 | const DrawContext = struct { 227 | start: LocalDate, 228 | end: LocalDate, 229 | row_offset: usize, 230 | max_prelude_len: usize, 231 | }; 232 | 233 | pub fn drawChains(chains: []const *main.Chain, links: []const main.Link, start: LocalDate, end: LocalDate) !void { 234 | var sow = std.io.getStdOut().writer(); 235 | var buffered_writer = std.io.bufferedWriter(sow); 236 | var w = buffered_writer.writer(); 237 | var ctx: DrawContext = .{ 238 | .row_offset = 0, 239 | .start = start, 240 | .end = end, 241 | .max_prelude_len = blk: { 242 | var max_prelude_len: usize = 0; 243 | for (chains, 0..) |chain, i| { 244 | // TODO: don't format this twice (once here and once on print) 245 | max_prelude_len = @max(max_prelude_len, formatPrelude(i, chain).len); 246 | } 247 | // +2 here to ensure same spacing between prelude and chains everywhere 248 | break :blk max_prelude_len + 2; 249 | }, 250 | }; 251 | 252 | w.writeByte('\n') catch unreachable; 253 | try drawHeader(w, &ctx); 254 | verticalSpace(w, 1); 255 | 256 | // TODO: better way of doing this 257 | for (chains, 0..) |chain, i| { 258 | var link_index: usize = 0; 259 | while (link_index < links.len and links[link_index].chain_id != chain.id) : (link_index += 1) {} 260 | const link_start = link_index; 261 | while (link_index < links.len) { 262 | const link = links[link_index]; 263 | if (link.chain_id == chain.id) { 264 | link_index += 1; 265 | } else { 266 | break; 267 | } 268 | } 269 | const ls = links[link_start..link_index]; 270 | 271 | switch (chain.kind) { 272 | inline else => |kind| try drawChain(kind, w, &ctx, chain, ls, i), 273 | } 274 | verticalSpace(w, 2); 275 | } 276 | w.writeByte('\n') catch unreachable; 277 | buffered_writer.flush() catch unreachable; 278 | } 279 | 280 | pub fn drawChainInfo(chain: *const main.Chain, links: []const main.Link, stats: *const Stats, start: LocalDate, end: LocalDate) !void { 281 | var sow = std.io.getStdOut().writer(); 282 | var buffered_writer = std.io.bufferedWriter(sow); 283 | var w = buffered_writer.writer(); 284 | var ctx: DrawContext = .{ 285 | .row_offset = 0, 286 | .start = start, 287 | .end = end, 288 | .max_prelude_len = 0, 289 | }; 290 | 291 | w.writeByte('\n') catch unreachable; 292 | try drawHeader(w, &ctx); 293 | verticalSpace(w, 1); 294 | switch (chain.kind) { 295 | inline else => |kind| try drawChain(kind, w, &ctx, chain, links, 0), 296 | } 297 | verticalSpace(w, 2); 298 | 299 | writeText(w, "Details", ""); 300 | writeText(w, " Id:", main.scratchPrint("{d}", .{chain.id})); 301 | writeText(w, " Name:", main.scratchPrint("{s}", .{chain.name[0..chain.name_len]})); 302 | writeText(w, " Color:", main.scratchPrint("{s}", .{chain.color.toHex()})); 303 | writeText(w, " Kind:", main.scratchPrint("{s}", .{@tagName(chain.kind)})); 304 | writeText(w, " Created:", main.scratchPrint("{s}", .{LocalDate.fromEpoch(chain.created).asString()})); 305 | writeText(w, " Stopped:", main.scratchPrint("{s}", .{if (chain.isActive()) "false" else &LocalDate.fromEpoch(chain.stopped).asString()})); 306 | writeText(w, " Fulfillment:", main.scratchPrint("{s}", .{std.mem.sliceTo(&stats.fulfillment, 0)})); 307 | writeText(w, " Longest streak:", main.scratchPrint("{d}", .{stats.longest_streak})); 308 | writeText(w, " Times broken:", main.scratchPrint("{d}", .{stats.times_broken})); 309 | writeText(w, " Longest gap:", main.scratchPrint("{d}", .{stats.longest_gap})); 310 | verticalSpace(w, 1); 311 | 312 | const first_timestamp = if (links.len > 0) &LocalDate.fromEpoch(links[0].local()).asString() else "N/A"; 313 | const last_timestamp = if (links.len > 0) &LocalDate.fromEpoch(links[links.len - 1].local()).asString() else "N/A"; 314 | writeText(w, "Links", ""); 315 | writeText(w, " Number of links:", main.scratchPrint("{d}", .{links.len})); 316 | writeText(w, " First timestamp:", main.scratchPrint("{s}", .{first_timestamp})); 317 | writeText(w, " Last timestamp:", main.scratchPrint("{s}", .{last_timestamp})); 318 | 319 | buffered_writer.flush() catch unreachable; 320 | } 321 | 322 | fn writeText(w: anytype, left: []const u8, right: []const u8) void { 323 | const cols = 40; 324 | const padding = cols - left.len - right.len; 325 | 326 | w.writeAll(left) catch unreachable; 327 | indent(w, padding); 328 | w.writeAll(right) catch unreachable; 329 | w.writeByte('\n') catch unreachable; 330 | indent(w, default_margin); 331 | } 332 | 333 | pub fn drawLinkInfo(chain: *const main.Chain, links: []const main.Link, link_index: usize) !void { 334 | var sow = std.io.getStdOut().writer(); 335 | var buffered_writer = std.io.bufferedWriter(sow); 336 | var w = buffered_writer.writer(); 337 | const cols: usize = 40; 338 | const mid = cols / 2; 339 | const in = mid - 3; 340 | const link = links[link_index]; 341 | const link_color = if (link.tags != 0) 342 | chain.tagColor(link.tags) 343 | else 344 | chain.color; 345 | 346 | verticalSpace(w, default_margin); 347 | indent(w, in); 348 | drawGlyphs(w, .box, link_color, 3); 349 | verticalSpace(w, 1); 350 | 351 | indent(w, in - 6); 352 | 353 | // TODO: check if the left link exists (it might be unlinked) 354 | const has_left_link = link_index > 0; 355 | if (has_left_link) { 356 | drawGlyph(w, .dash, color.sand); 357 | drawGlyph(w, .mesh, chain.color); 358 | drawGlyph(w, .line, color.sand); 359 | } else { 360 | indent(w, 6); 361 | } 362 | 363 | drawGlyphs(w, .box, link_color, 3); 364 | 365 | // TODO: check if the right link exists (it might be unlinked) 366 | const has_right_link = link_index < links.len - 1; 367 | if (has_right_link) { 368 | drawGlyph(w, .line, color.sand); 369 | drawGlyph(w, .mesh, chain.color); 370 | drawGlyph(w, .dash, color.sand); 371 | } 372 | 373 | verticalSpace(w, 1); 374 | 375 | indent(w, in); 376 | drawGlyphs(w, .box, link_color, 3); 377 | 378 | verticalSpace(w, 2); 379 | 380 | writeText(w, "Chain ID:", main.scratchPrint("{d}", .{link.chain_id})); 381 | const created_str = date.LocalDateTime.fromEpoch(link.local()).asString(); 382 | writeText(w, "Created:", main.scratchPrint("{s}", .{created_str})); 383 | 384 | const tags_str = if (link.tags == 0) blk: { 385 | break :blk main.scratchPrint("[]", .{}); 386 | } else blk: { 387 | var buf = std.mem.zeroes([main.max_tags * main.tag_name_max_len]u8); 388 | var fba = std.io.fixedBufferStream(&buf); 389 | const bw = fba.writer(); 390 | const tags = chain.getTags(); 391 | var link_tags = link.tags; 392 | const n_tags = @popCount(link_tags); 393 | for (tags, 1..) |tag, i| { 394 | const has_tag = link_tags & 1 == 1; 395 | if (has_tag) { 396 | try bw.writeAll(tag.getName()); 397 | if (i != n_tags) 398 | try bw.writeAll(","); 399 | } 400 | link_tags >>= 1; 401 | } 402 | break :blk main.scratchPrint("{s}", .{fba.getWritten()}); 403 | }; 404 | writeText(w, "Tags: ", tags_str); 405 | 406 | buffered_writer.flush() catch unreachable; 407 | } 408 | --------------------------------------------------------------------------------