Hydration Tracker

@itzchillv1.0.1

A daily water counter with a compact HUD and one-key logging.

Files

main.luau

--[[
  rebind: min_sdk=3.9.0
  rebind: permissions=net
  rebind: tick_rate=10
  rebind: memory_limit_mb=16
]]

local STATE_FILE = "hydration.json"
local COLORS = {
  panel = "#0F1414F5",
  text = "#F4F1E8",
  soft = "#D8DDD3",
  muted = "#98A398",
  accent = "#7ED7E8",
  line = "#263335",
}

local cfg = rb.ui.schema({
  goal = rb.ui.slider(8, {
    min = 1,
    max = 16,
    step = 1,
    label = "Daily goal (glasses)",
    group = "Goal",
  }),
  add_key = rb.ui.keybind("Ctrl+Alt+H", {
    label = "Add a glass",
    group = "Hotkeys",
  }),
  reset_key = rb.ui.keybind("Ctrl+Alt+Shift+H", {
    label = "Reset today",
    group = "Hotkeys",
  }),
  auto_reset = rb.ui.toggle(true, {
    label = "Reset automatically each day",
    group = "Behavior",
  }),
  show_hud = rb.ui.toggle(true, {
    label = "Show counter",
    group = "HUD",
  }),
  hud_anchor = rb.ui.select("top-right", {
    "top-left",
    "top-right",
    "bottom-left",
    "bottom-right",
  }, {
    label = "Counter position",
    group = "HUD",
  }),
})

local state = {
  count = 0,
  date = "",
  surface = nil,
  signature = nil,
  timer = nil,
}

local function today()
  return os.date("%Y-%m-%d")
end

local function clamp(value, minimum, maximum)
  return math.max(minimum, math.min(value, maximum))
end

local function add_rect(ops, x, y, width, height, fill, radius, border, border_width)
  local op = {
    kind = "rect",
    x = x,
    y = y,
    w = width,
    h = height,
    fill = fill,
    radius = radius or 0,
  }
  if border then
    op.border = border
    op.border_width = border_width or 1
  end
  ops[#ops + 1] = op
end

local function add_text(ops, x, y, size, value, color, bold)
  ops[#ops + 1] = {
    kind = "text",
    x = x,
    y = y,
    size = size,
    text = value,
    color = color,
    bold = bold == true,
  }
end

local function notify(message)
  pcall(rb.overlay.toast, message, {
    title = "Hydration",
    duration = 2400,
  })
end

local function remove_surface()
  if state.surface then
    pcall(function()
      state.surface:remove()
    end)
    state.surface = nil
  end
end

local function save_state()
  local ok = pcall(rb.file.write_json, STATE_FILE, {
    count = state.count,
    date = state.date,
  })
  if not ok then
    rb.log.warn("Hydration: could not save " .. STATE_FILE)
  end
end

local function refresh_day()
  local current = today()
  if state.date ~= current then
    if cfg.auto_reset ~= false then
      state.count = 0
    end
    state.date = current
    save_state()
    state.signature = nil
    return true
  end
  return false
end

local function draw_hud()
  if cfg.show_hud == false then
    remove_surface()
    state.signature = nil
    return
  end

  local goal = clamp(tonumber(cfg.goal) or 8, 1, 16)
  local count = clamp(state.count, 0, goal)
  local fraction = count / goal
  local signature = tostring(count) .. "/" .. tostring(goal) .. "|" .. tostring(cfg.hud_anchor)
  if signature == state.signature then
    return
  end
  state.signature = signature

  local ops = {}
  add_rect(ops, 0, 0, 280, 92, COLORS.panel, 14, COLORS.line, 1)
  add_rect(ops, 16, 16, 3, 60, COLORS.accent, 1)
  add_text(ops, 28, 12, 10, "HYDRATION", COLORS.muted, true)
  add_text(ops, 28, 27, 26, tostring(count) .. " / " .. tostring(goal), COLORS.text, true)
  add_text(ops, 28, 62, 9, "Ctrl+Alt+H  add glass", COLORS.muted)

  add_rect(ops, 16, 78, 248, 6, "#223034", 3)
  if fraction > 0 then
    add_rect(ops, 16, 78, math.max(2, 248 * fraction), 6, COLORS.accent, 3)
  end

  if count >= goal then
    add_text(ops, 176, 12, 9, "GOAL MET", COLORS.accent, true)
  end

  if state.surface then
    local ok = pcall(function()
      state.surface:set({
        width = 280,
        height = 92,
        ops = ops,
      })
    end)
    if not ok then
      remove_surface()
    end
  end

  if not state.surface then
    local ok, surface = pcall(rb.overlay.surface, {
      anchor = cfg.hud_anchor,
      width = 280,
      height = 92,
      ops = ops,
    })
    if ok then
      state.surface = surface
    else
      rb.log.warn("Hydration: could not create surface: " .. tostring(surface))
    end
  end
end

local function add_glass()
  refresh_day()
  local goal = clamp(tonumber(cfg.goal) or 8, 1, 16)
  if state.count >= goal then
    notify("Daily goal already reached.")
    return
  end
  state.count = math.min(goal, state.count + 1)
  save_state()
  state.signature = nil
  draw_hud()
  if state.count >= goal then
    notify("Daily goal reached — " .. tostring(goal) .. " glasses.")
  else
    notify(tostring(state.count) .. " / " .. tostring(goal) .. " glasses")
  end
end

local function reset_today()
  state.count = 0
  state.date = today()
  save_state()
  state.signature = nil
  draw_hud()
  notify("Today's count reset.")
end

rb.bind(cfg.add_key, add_glass)
rb.bind(cfg.reset_key, reset_today)

function OnStart()
  local ok, data = pcall(rb.file.read_json, STATE_FILE)
  if ok and type(data) == "table" then
    state.count = math.max(0, math.floor(tonumber(data.count) or 0))
    state.date = tostring(data.date or "")
  end
  if state.date == "" then
    state.date = today()
  end
  refresh_day()
  state.count = clamp(state.count, 0, clamp(tonumber(cfg.goal) or 8, 1, 16))
  draw_hud()
  state.timer = rb.timer.every(60000, function()
    if refresh_day() then
      draw_hud()
    end
  end)
end

function OnStop()
  if state.timer then
    pcall(function()
      state.timer:stop()
    end)
    state.timer = nil
  end
  save_state()
  remove_surface()
end