package main
import "core:flags"
import "core:fmt"
import "core:strconv"
import "core:strings"
import "core:time"
import "../core"
Command :: enum {
Usage,
Add,
Symptom,
Event,
Today,
Week,
Show,
Coffee,
Events,
Version,
}
Command_Func :: distinct proc(args: []string, store: ^core.Storage)
Command_Data := [Command]struct {
func: Command_Func,
desc: string,
} {
.Usage = {func = command_usage, desc = "this screen"},
.Add = {func = command_add, desc = "log today's daily entry"},
.Symptom = {func = command_symptom, desc = "note a symptom"},
.Coffee = {func = command_coffee, desc = "alias to note a cup of coffee"},
.Event = {func = command_event, desc = "log a timestamped event"},
.Today = {func = command_today, desc = "show today"},
.Week = {func = command_week, desc = "show current week"},
.Show = {func = command_show, desc = "show a specific date"},
.Events = {func = command_events, desc = "list events"},
.Version = {func = command_version, desc = "print version"},
}
command_from_string :: proc(cmd_str: string) -> Command {
cmd: Command
switch cmd_str {
case "usage":
cmd = .Usage
case "add":
cmd = .Add
case "symptom":
cmd = .Symptom
case "coffee":
cmd = .Coffee
case "event":
cmd = .Event
case "today":
cmd = .Today
case "week":
cmd = .Week
case "show":
cmd = .Show
case "events":
cmd = .Events
case "version":
cmd = .Version
}
return cmd
}
command_usage :: proc(args: []string, store: ^core.Storage) {
fmt.printf("%s🧌 Data Goblin%s v%s\n\n", core.BOLD, core.RESET, "0.1")
fmt.printf(" %shoarding tiny truths since 2026%s\n", core.DIM, core.RESET)
fmt.printf(" %sThe goblin stares at you. You forgot to say what you want.%s\n\n", core.DIM, core.RESET)
fmt.printf(" Available commands:\n\n")
for data, cmd in Command_Data {
fmt.printf(
" %s%-14s%s %s%s%s\n",
core.GREEN,
strings.to_lower(fmt.tprintf("%s", cmd), context.temp_allocator),
core.RESET,
core.DIM,
data.desc,
core.RESET,
)
}
fmt.printf("\n Usage: %sdgob [flags]%s\n\n", core.YELLOW, core.RESET)
}
Init_Options :: struct {
db: string `usage:"path to sqlite-file to use (defaults to ./data.db)"`,
}
validate_ranges :: proc(model: rawptr, name: string, value: any, args_tag: string) -> string {
switch name {
case "mood", "sleep_quality":
if n, ok := value.(int); ok {
if n < 1 || n > 10 {
return "must be between 1 and 10"
}
}
case "sleep":
if n, ok := value.(f64); ok {
if n < 0 || n > 24 {
return "must be between 0.0 and 24.0"
}
}
}
return ""
}
Add_Options :: struct {
mood: int `args:"required" usage:"mood of the day, 1-10"`,
sleep: f64 `args:"required" usage:"the amount of slept hours 0.0-24.0"`,
sleep_quality: int `args:"required" usage:"the previous night's sleep quality, 1-10"`,
date: time.Time `usage:"date to record this data to (defaults to today)"`,
notes: string `usage:"any extra notes for the day"`,
}
command_add :: proc(args: []string, store: ^core.Storage) {
opt: Add_Options
flags.register_flag_checker(validate_ranges)
flags.parse_or_exit(&opt, args, .Unix)
if time.time_to_unix(opt.date) == 0 {
opt.date = time.now()
}
// FIXME: remove timezone offset
if err := core.storage_add_to_daily_log(store^, opt.mood, opt.sleep, opt.sleep_quality, opt.date, opt.notes);
err != nil {
fmt.eprintf("failed adding log: %v\n", err)
}
tz_date := core.new_time_with_timezone_offset(opt.date)
buf: [time.MIN_YYYY_DATE_LEN]u8
fmt.printf(
" hoarded: daily log for %s %s✓%s\n",
time.to_string_yyyy_mm_dd(tz_date, buf[:]),
core.GREEN,
core.RESET,
)
}
Symptom_Options :: struct {
symptom: string `args:"required" usage:"felt symptom"`,
severity: int `args:"required" usage:"the severity of the symptom, 1-10"`,
date: time.Time `usage:"date and time to record this data to (defaults to now, RFC 3339)"`,
notes: string `usage:"any extra notes for the day"`,
}
command_symptom :: proc(args: []string, store: ^core.Storage) {
opt: Symptom_Options
flags.register_flag_checker(validate_ranges)
flags.parse_or_exit(&opt, args, .Unix)
if time.time_to_unix(opt.date) == 0 {
opt.date = time.now()
}
if err := core.storage_add_to_symptoms(store^, opt.symptom, opt.severity, opt.date, opt.notes); err != nil {
fmt.eprintf("failed adding symptom: %v\n", err)
}
tz_date := core.new_time_with_timezone_offset(opt.date)
date_buf: [time.MIN_YYYY_DATE_LEN]u8
time_buf: [time.MIN_HMS_LEN]u8
fmt.printf(
" hoarded: symptom for %s %s@%s %s %s✓%s\n",
time.to_string_yyyy_mm_dd(tz_date, date_buf[:]),
core.DIM,
time.to_string_hms(tz_date, time_buf[:]),
core.RESET,
core.GREEN,
core.RESET,
)
}
Event_Options :: struct {
kind: string `args:"required" usage:"what kind of event"`,
amount: f64 `usage:"the amount of event (if applicable, say 'coffe')"`,
unit: string `usage:"the unit of the amount (if applicable, say a 'cup' of coffe)"`,
date: time.Time `usage:"date and time to record this data to (defaults to now, RFC 3339)"`,
}
// FIXME: Simplify, this is ridiculous
command_coffee :: proc(args: []string, store: ^core.Storage) {
command_event({"event", "--kind", "coffee", "--amount", "1", "--unit", "cup"}, store)
}
// FIXME: Only known kinds
// FIXME: Limit valid units?
// FIXME: Validate that unit is set if amount is set
command_event :: proc(args: []string, store: ^core.Storage) {
opt: Event_Options
flags.parse_or_exit(&opt, args, .Unix)
if time.time_to_unix(opt.date) == 0 {
opt.date = time.now()
}
if err := core.storage_add_to_events(store^, opt.kind, opt.amount, opt.unit, opt.date); err != nil {
fmt.eprintf("failed adding event: %v\n", err)
}
tz_date := core.new_time_with_timezone_offset(opt.date)
date_buf: [time.MIN_YYYY_DATE_LEN]u8
time_buf: [time.MIN_HMS_LEN]u8
fmt.printf(
" hoarded: event for %s %s@%s %s %s✓%s\n",
time.to_string_yyyy_mm_dd(tz_date, date_buf[:]),
core.DIM,
time.to_string_hms(tz_date, time_buf[:]),
core.RESET,
core.GREEN,
core.RESET,
)
}
command_today :: proc(args: []string, store: ^core.Storage) {
buf: [time.MIN_YYYY_DATE_LEN]u8
command_show({"show", time.to_string_yyyy_mm_dd(time.now(), buf[:])}, store)
}
SPARK_CHARS :: []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
spark_char :: proc(val, max: int) -> string {
spark_chars := SPARK_CHARS
idx := int((f64(val) / f64(max)) * f64(len(SPARK_CHARS) - 1))
return spark_chars[clamp(idx, 0, len(SPARK_CHARS) - 1)]
}
spark_days :: proc(values: []int) -> string {
b, err := strings.builder_make(context.temp_allocator)
ensure(err == nil)
defer strings.builder_destroy(&b)
num_values := len(values)
for v in values {
if v < 0 {
strings.write_string(&b, core.DIM)
strings.write_string(&b, "· ")
strings.write_string(&b, core.RESET)
continue
}
color := 31
p := f64(v) / 10
if p > 0.25 {
color = 33
}
if p > 0.75 {
color = 32
}
strings.write_string(&b, fmt.tprintf("\e[%dm", color))
strings.write_string(&b, spark_char(v, 10))
strings.write_string(&b, " ")
strings.write_string(&b, core.RESET)
}
strings.write_string(&b, core.DIM)
for _ in num_values ..< 7 {
strings.write_string(&b, "· ")
}
strings.write_string(&b, core.RESET)
return strings.to_string(b)
}
// FIXME: naive, does not take gaps in days into account
command_week :: proc(args: []string, store: ^core.Storage) {
day := time.Hour * 24
today := time.now()
weekday := int(time.weekday(today))
// NOTE: Corrects for Sunday being first day of the week in the library
weekday = (weekday + int(time.Weekday.Saturday)) % (int(time.Weekday.Saturday) + 1)
week_start := time.time_add(today, -(day * time.Duration(weekday)))
week_end := time.time_add(week_start, day * 6)
week_start_buf: [time.MIN_YYYY_DATE_LEN]u8
week_end_buf: [time.MIN_YYYY_DATE_LEN]u8
week_start_ymd := time.to_string_yyyy_mm_dd(week_start, week_start_buf[:])
week_end_ymd := time.to_string_yyyy_mm_dd(week_end, week_end_buf[:])
fmt.printf(
"%s────────────────────────────────────────%s\n",
core.DIM,
core.RESET,
)
fmt.printf("%s◆ week of %s - %s%s\n", core.YELLOW, week_start_ymd, week_end_ymd, core.RESET)
if data, get_err := core.storage_get_daily_log_span(store^, week_start, week_end);
len(data) == 0 || get_err != nil {
fmt.printf(" %sno data for this week%s\n", core.RED, core.RESET)
} else {
fmt.printf("\n%sDAILY LOG%s\n", core.DIM, core.RESET)
fmt.printf("\n\t\t%smon tue wed thu fri sat sun%s\n", core.DIM, core.RESET)
current := week_start
current_buf: [time.MIN_YYYY_DATE_LEN]u8
current_str_ymd := time.to_string_yyyy_mm_dd(current, current_buf[:])
mood_values: [dynamic; 7]int
sleep_values: [dynamic; 7]int
sleep_quality_values: [dynamic; 7]int
for d in data {
for d.date != current_str_ymd {
append(&mood_values, -1)
append(&sleep_values, -1)
append(&sleep_quality_values, -1)
current = time.time_add(current, day)
current_str_ymd = time.to_string_yyyy_mm_dd(current, current_buf[:])
}
append(&mood_values, d.mood)
append(&sleep_values, int((d.sleep / 8) * 10))
append(&sleep_quality_values, d.sleep_quality)
current = time.time_add(current, day)
current_str_ymd = time.to_string_yyyy_mm_dd(current, current_buf[:])
}
fmt.printf(" %smood%s\t\t%s%s\n", core.DIM, core.RESET, spark_days(mood_values[:]), core.RESET)
fmt.printf(" %ssleep%s\t\t%s%s\n", core.DIM, core.RESET, spark_days(sleep_values[:]), core.RESET)
fmt.printf(
" %ssleep quality%s\t%s%s\n",
core.DIM,
core.RESET,
spark_days(sleep_quality_values[:]),
core.RESET,
)
}
fmt.printf(
"%s────────────────────────────────────────%s\n",
core.DIM,
core.RESET,
)
}
build_bar :: proc(b: ^strings.Builder, val, max, width: int, invert := false) -> string {
strings.builder_reset(b)
filled := int(f64(val * width) / f64(max))
p := f64(filled) / f64(width)
for i in 0 ..< width {
color := 31 if !invert else 32
if p > 0.25 {
color = 33
}
if p > 0.75 {
color = 32 if !invert else 31
}
strings.write_string(b, fmt.tprintf("\e[%s%dm", "2;" if i >= filled else "", color))
strings.write_rune(b, '█')
strings.write_string(b, core.RESET)
}
return strings.to_string(b^)
}
command_show :: proc(args: []string, store: ^core.Storage) {
if len(args) < 2 {
// TODO: print usage
return
}
date_parts := strings.split(args[1], "-", context.temp_allocator)
yyyy, _ := strconv.parse_i64(date_parts[0])
mm, _ := strconv.parse_i64(date_parts[1])
dd, _ := strconv.parse_i64(date_parts[2])
date, ok := time.components_to_time(yyyy, mm, dd, 0, 0, 0)
ensure(ok)
// TODO: print usage/invalid date
b, err := strings.builder_make_len(10)
ensure(err == nil)
defer strings.builder_destroy(&b)
fmt.printf(
"%s────────────────────────────────────────%s\n",
core.DIM,
core.RESET,
)
day_name := strings.to_lower(fmt.tprintf("%s", time.weekday(date)), context.temp_allocator)
fmt.printf("%s◆ %s, %s%s\n", core.YELLOW, day_name, args[1], core.RESET)
if data, get_err := core.storage_get_daily_log(store^, date); get_err != nil {
fmt.printf(" %sno daily data for this day%s\n", core.RED, core.RESET)
} else {
fmt.printf("\n%sDAILY LOG%s\n", core.DIM, core.RESET)
fmt.printf(
" %smood%s\t\t%s %d/%s10%s\n",
core.DIM,
core.RESET,
build_bar(&b, data.mood, 10, 10),
data.mood,
core.DIM,
core.RESET,
)
fmt.printf(
" %ssleep%s\t\t%s %.1f%sh%s\n",
core.DIM,
core.RESET,
build_bar(&b, int(data.sleep * 10), 80, 10),
data.sleep,
core.DIM,
core.RESET,
)
fmt.printf(
" %ssleep quality%s\t%s %d/%s10%s\n",
core.DIM,
core.RESET,
build_bar(&b, data.sleep_quality, 10, 10),
data.sleep_quality,
core.DIM,
core.RESET,
)
fmt.printf(" %snotes%s\t\t%s\n", core.DIM, core.RESET, data.notes)
}
if data, get_err := core.storage_get_daily_symptoms(store^, date); get_err == nil {
fmt.printf("\n%sSYMPTOMS%s\n", core.DIM, core.RESET)
for d in data {
fmt.printf(
" %s%s%s\t%s %d/%s10%s\n",
core.DIM,
d.symptom,
core.RESET,
build_bar(&b, d.severity, 10, 10, true),
d.severity,
core.DIM,
core.RESET,
)
}
}
if data, get_err := core.storage_get_daily_events(store^, date); get_err == nil {
fmt.printf("\n%sEVENTS%s\n", core.DIM, core.RESET)
for d in data {
tz_time := core.new_time_with_timezone_offset(d.timestamp)
hh, mm, _ := time.clock_from_time(tz_time)
icon: string
switch d.kind {
case "coffee":
icon = "☕ "
case "walk":
icon = "🚶 "
case "painkiller":
icon = "💊 "
}
amount := fmt.tprintf("%s · %.1f %s%s", core.DIM, d.amount, d.unit, core.RESET) if d.amount > 1 else ""
fmt.printf(" %s%02d:%02d%s %s%s%s\n", core.DIM, hh, mm, core.RESET, icon, d.kind, amount)
}
}
fmt.printf(
"%s────────────────────────────────────────%s\n",
core.DIM,
core.RESET,
)
}
command_events :: proc(args: []string, store: ^core.Storage) {
fmt.printf("events\n")
fmt.printf("args: %v\n", args)
}
command_version :: proc(args: []string, store: ^core.Storage) {
fmt.printf("version\n")
fmt.printf("args: %v\n", args)
}