diff --git a/README.md b/README.md
index 204c340..082e72d 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ QueryCanary is a lightweight tool for monitoring your production data using SQL.
## What It Does
- ✅ Run SQL checks against your production database
-- ✅ Schedule checks using flexible cron expressions
+- ✅ Schedule checks with simple choices, local times, or custom cron expressions
- ✅ Get alerts via email or Slack when values drift or break
- ✅ See historical trends and chart results over time
- ✅ Build custom reports that group metrics from multiple data sources
diff --git a/assets/js/app.js b/assets/js/app.js
index 80e3833..e6c0e26 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -28,6 +28,7 @@ import SQLEditor from "./hooks/sql_editor";
import CheckChart from "./hooks/check_chart";
import HomeChart from "./hooks/home_chart";
import ReportMetricDrag from "./hooks/report_metric_drag";
+import ScheduleTimezone from "./hooks/schedule_timezone";
import "prismjs";
import "prismjs/components/prism-sql";
@@ -37,6 +38,7 @@ Hooks.SQLEditor = SQLEditor;
Hooks.CheckChart = CheckChart;
Hooks.HomeChart = HomeChart;
Hooks.ReportMetricDrag = ReportMetricDrag;
+Hooks.ScheduleTimezone = ScheduleTimezone;
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
diff --git a/assets/js/hooks/schedule_timezone.js b/assets/js/hooks/schedule_timezone.js
new file mode 100644
index 0000000..12cb5aa
--- /dev/null
+++ b/assets/js/hooks/schedule_timezone.js
@@ -0,0 +1,12 @@
+export default {
+ mounted() {
+ if (this.el.dataset.autoTimezone !== "true") return;
+
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const select = this.el.querySelector("[data-timezone-select]");
+
+ if (timezone && select && [...select.options].some(option => option.value === timezone)) {
+ this.pushEvent("detect_timezone", {timezone});
+ }
+ }
+};
diff --git a/lib/query_canary/checks/check.ex b/lib/query_canary/checks/check.ex
index 110451c..b4eea87 100644
--- a/lib/query_canary/checks/check.ex
+++ b/lib/query_canary/checks/check.ex
@@ -9,6 +9,7 @@ defmodule QueryCanary.Checks.Check do
schema "checks" do
field :name, :string
field :schedule, :string
+ field :timezone, :string, default: "Etc/UTC"
field :enabled, :boolean, default: false
field :query, :string
field :expectation, :map
@@ -30,6 +31,7 @@ defmodule QueryCanary.Checks.Check do
|> cast(attrs, [
:name,
:schedule,
+ :timezone,
:enabled,
:query,
:server_id,
@@ -40,12 +42,14 @@ defmodule QueryCanary.Checks.Check do
|> validate_required([
:name,
:schedule,
+ :timezone,
:enabled,
:query,
:server_id,
:notification_preferences
])
|> validate_cron_expression(:schedule)
+ |> validate_timezone()
|> foreign_key_constraint(:server_id)
|> validate_server_id_unchanged()
|> validate_server_access(user_scope)
@@ -96,6 +100,15 @@ defmodule QueryCanary.Checks.Check do
end)
end
+ defp validate_timezone(changeset) do
+ validate_change(changeset, :timezone, fn _, timezone ->
+ case DateTime.shift_zone(DateTime.utc_now(), timezone) do
+ {:ok, _} -> []
+ {:error, _} -> [timezone: "is not a valid time zone"]
+ end
+ end)
+ end
+
defp validate_server_access(changeset, user_scope) do
server_id = get_field(changeset, :server_id)
diff --git a/lib/query_canary/checks/schedule.ex b/lib/query_canary/checks/schedule.ex
new file mode 100644
index 0000000..cf1e756
--- /dev/null
+++ b/lib/query_canary/checks/schedule.ex
@@ -0,0 +1,227 @@
+defmodule QueryCanary.Checks.Schedule do
+ @moduledoc "Converts common check schedules to cron and evaluates them in a check's time zone."
+
+ @intervals %{
+ "minute" => "* * * * *",
+ "5_minutes" => "*/5 * * * *",
+ "15_minutes" => "*/15 * * * *",
+ "30_minutes" => "*/30 * * * *",
+ "hourly" => "0 * * * *"
+ }
+
+ @weekdays ~w(Sunday Monday Tuesday Wednesday Thursday Friday Saturday)
+
+ def options do
+ [
+ {"Every minute", "minute"},
+ {"Every 5 minutes", "5_minutes"},
+ {"Every 15 minutes", "15_minutes"},
+ {"Every 30 minutes", "30_minutes"},
+ {"Every hour", "hourly"},
+ {"Every day", "daily"},
+ {"Weekdays", "weekdays"},
+ {"Every week", "weekly"},
+ {"Every month", "monthly"},
+ {"Custom cron", "custom"}
+ ]
+ end
+
+ def weekdays, do: Enum.with_index(@weekdays) |> Enum.map(fn {name, day} -> {name, day} end)
+ def monthdays, do: Enum.map(1..28, &{Integer.to_string(&1), &1})
+
+ def from_cron(nil), do: from_cron("0 8 * * *")
+
+ def from_cron(cron) do
+ default = %{"kind" => "custom", "time" => "08:00", "weekday" => "1", "monthday" => "1"}
+
+ cond do
+ kind =
+ Enum.find_value(@intervals, fn {kind, expression} -> if expression == cron, do: kind end) ->
+ %{default | "kind" => kind}
+
+ match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* \*$/, cron) ->
+ time_ui(default, "daily", match)
+
+ match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* 1-5$/, cron) ->
+ time_ui(default, "weekdays", match)
+
+ match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* ([0-6])$/, cron) ->
+ [_, minute, hour, weekday] = match
+ time_ui(default, "weekly", [nil, minute, hour]) |> Map.put("weekday", weekday)
+
+ match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) (\d{1,2}) \* \*$/, cron) ->
+ [_, minute, hour, monthday] = match
+
+ if String.to_integer(monthday) in 1..28 do
+ time_ui(default, "monthly", [nil, minute, hour]) |> Map.put("monthday", monthday)
+ else
+ default
+ end
+
+ true ->
+ default
+ end
+ end
+
+ defp time_ui(default, kind, [_, minute, hour]) do
+ case Time.new(String.to_integer(hour), String.to_integer(minute), 0) do
+ {:ok, _} -> %{default | "kind" => kind, "time" => pad(hour) <> ":" <> pad(minute)}
+ _ -> default
+ end
+ end
+
+ defp pad(value),
+ do: value |> String.to_integer() |> Integer.to_string() |> String.pad_leading(2, "0")
+
+ def to_cron(%{"kind" => kind} = ui) do
+ cond do
+ Map.has_key?(@intervals, kind) ->
+ {:ok, Map.fetch!(@intervals, kind)}
+
+ kind in ~w(daily weekdays weekly monthly) ->
+ with {:ok, time} <- Time.from_iso8601(Map.get(ui, "time", "") <> ":00") do
+ minute = time.minute
+ hour = time.hour
+
+ case kind do
+ "daily" -> {:ok, "#{minute} #{hour} * * *"}
+ "weekdays" -> {:ok, "#{minute} #{hour} * * 1-5"}
+ "weekly" -> numbered_cron(ui["weekday"], 0..6, "#{minute} #{hour} * * ")
+ "monthly" -> numbered_cron(ui["monthday"], 1..28, "#{minute} #{hour} ", " * *")
+ end
+ else
+ _ -> {:error, "Choose a valid time"}
+ end
+
+ true ->
+ {:error, "Choose a schedule"}
+ end
+ end
+
+ defp numbered_cron(value, range, prefix, suffix \\ "") do
+ case Integer.parse(to_string(value)) do
+ {number, ""} ->
+ if number in range,
+ do: {:ok, prefix <> Integer.to_string(number) <> suffix},
+ else: {:error, "Choose a valid day"}
+
+ _ ->
+ {:error, "Choose a valid day"}
+ end
+ end
+
+ def description(cron, timezone) do
+ ui = from_cron(cron)
+ kind = ui["kind"]
+
+ case kind do
+ "minute" ->
+ "Every minute"
+
+ "5_minutes" ->
+ "Every 5 minutes"
+
+ "15_minutes" ->
+ "Every 15 minutes"
+
+ "30_minutes" ->
+ "Every 30 minutes"
+
+ "hourly" ->
+ "Every hour"
+
+ "daily" ->
+ "Every day at #{display_time(ui)} (#{timezone})"
+
+ "weekdays" ->
+ "Weekdays at #{display_time(ui)} (#{timezone})"
+
+ "weekly" ->
+ "Every #{Enum.at(@weekdays, String.to_integer(ui["weekday"]))} at #{display_time(ui)} (#{timezone})"
+
+ "monthly" ->
+ "Day #{ui["monthday"]} of each month at #{display_time(ui)} (#{timezone})"
+
+ _ ->
+ "Custom cron: #{cron} (#{timezone})"
+ end
+ end
+
+ defp display_time(ui) do
+ {:ok, time} = Time.from_iso8601(ui["time"] <> ":00")
+ Calendar.strftime(time, "%-I:%M %p")
+ end
+
+ def matches?(cron, timezone, %DateTime{} = utc_now) do
+ with {:ok, expression} <- Crontab.CronExpression.Parser.parse(cron),
+ {:ok, local} <- DateTime.shift_zone(utc_now, timezone) do
+ Crontab.DateChecker.matches_date?(expression, local) and
+ not repeated_calendar_time?(cron, local)
+ else
+ _ -> false
+ end
+ end
+
+ def next_runs(cron, timezone, now \\ DateTime.utc_now(), count \\ 3) do
+ with {:ok, expression} <- Crontab.CronExpression.Parser.parse(cron),
+ {:ok, local_now} <- DateTime.shift_zone(now, timezone) do
+ # Start before the current local hour so a repeated DST hour is included.
+ start_at = local_now |> DateTime.to_naive() |> NaiveDateTime.add(-7200, :second)
+ boundary = local_now |> DateTime.to_naive() |> NaiveDateTime.add(10_800, :second)
+
+ expression
+ |> Crontab.Scheduler.get_next_run_dates(start_at)
+ |> candidates_through(boundary, count, now, timezone, cron)
+ |> Enum.sort(&(DateTime.compare(&1, &2) != :gt))
+ |> Enum.filter(&(DateTime.compare(&1, now) == :gt))
+ |> Enum.take(count)
+ else
+ _ -> []
+ end
+ rescue
+ _ -> []
+ end
+
+ defp candidates_through(stream, boundary, count, now, timezone, cron) do
+ {runs, _} =
+ Enum.reduce_while(stream, {[], 0}, fn candidate, {acc, future_count} ->
+ resolved = resolve_local_time(candidate, timezone, cron)
+ future_count = future_count + Enum.count(resolved, &(DateTime.compare(&1, now) == :gt))
+ acc = Enum.reverse(resolved, acc)
+
+ if NaiveDateTime.compare(candidate, boundary) == :gt and future_count >= count do
+ {:halt, {acc, future_count}}
+ else
+ {:cont, {acc, future_count}}
+ end
+ end)
+
+ Enum.reverse(runs)
+ end
+
+ defp resolve_local_time(naive, timezone, cron) do
+ case DateTime.from_naive(naive, timezone) do
+ {:ok, datetime} ->
+ [datetime]
+
+ {:ambiguous, first, second} ->
+ if calendar_schedule?(cron), do: [first], else: [first, second]
+
+ {:gap, _, _} ->
+ []
+
+ _ ->
+ []
+ end
+ end
+
+ defp repeated_calendar_time?(cron, local) do
+ calendar_schedule?(cron) and
+ case DateTime.from_naive(DateTime.to_naive(local), local.time_zone) do
+ {:ambiguous, _first, second} -> DateTime.compare(local, second) == :eq
+ _ -> false
+ end
+ end
+
+ defp calendar_schedule?(cron), do: from_cron(cron)["kind"] in ~w(daily weekdays weekly monthly)
+end
diff --git a/lib/query_canary/jobs/check_scheduler.ex b/lib/query_canary/jobs/check_scheduler.ex
index d989194..f33f9df 100644
--- a/lib/query_canary/jobs/check_scheduler.ex
+++ b/lib/query_canary/jobs/check_scheduler.ex
@@ -4,6 +4,7 @@ defmodule QueryCanary.Jobs.CheckScheduler do
require Logger
alias QueryCanary.Jobs.CheckRunner
+ alias QueryCanary.Checks.Schedule
@impl Oban.Worker
def perform(_) do
@@ -13,18 +14,12 @@ defmodule QueryCanary.Jobs.CheckScheduler do
enabled_checks = QueryCanary.Checks.list_enabled_checks_for_everyone()
Enum.each(enabled_checks, fn check ->
- case Crontab.CronExpression.Parser.parse(check.schedule) do
- {:ok, expr} ->
- if Crontab.DateChecker.matches_date?(expr, now) do
- Logger.info("Scheduling check #{check.name} at #{now}")
+ if Schedule.matches?(check.schedule, check.timezone || "Etc/UTC", now) do
+ Logger.info("Scheduling check #{check.name} at #{now}")
- %{"id" => check.id}
- |> CheckRunner.new()
- |> Oban.insert()
- end
-
- {:error, reason} ->
- Logger.warning("Invalid cron: #{check.schedule} (#{reason})")
+ %{"id" => check.id}
+ |> CheckRunner.new()
+ |> Oban.insert()
end
end)
diff --git a/lib/query_canary_web/components/schedule_picker.ex b/lib/query_canary_web/components/schedule_picker.ex
new file mode 100644
index 0000000..8bad365
--- /dev/null
+++ b/lib/query_canary_web/components/schedule_picker.ex
@@ -0,0 +1,143 @@
+defmodule QueryCanaryWeb.Components.SchedulePicker do
+ use Phoenix.Component
+
+ alias QueryCanary.Checks.Schedule
+ import QueryCanaryWeb.CoreComponents, only: [input: 1]
+
+ attr :form, :any, required: true
+ attr :ui, :map, required: true
+ attr :next_runs, :list, required: true
+ attr :auto_timezone, :boolean, default: false
+
+ def schedule_picker(assigns) do
+ assigns =
+ assigns
+ |> assign(:options, Schedule.options())
+ |> assign(:weekdays, Schedule.weekdays())
+ |> assign(:monthdays, Schedule.monthdays())
+ |> assign(:timezones, Tzdata.zone_list() |> Enum.sort())
+
+ ~H"""
+
+
+
When should this check run?
+
+ Choose a common schedule or use cron for more control.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Choose 1–28 so the check runs every month.
+
+
+
+
+ <.input field={@form[:schedule]} type="text" label="Cron expression" placeholder="0 8 * * *" />
+
Five fields: minute hour day month weekday.
+
+ <.input :if={@ui["kind"] != "custom"} field={@form[:schedule]} type="hidden" />
+
+ {elem(error, 0)}
+
+
+
+
+
+
+ {elem(error, 0)}
+
+
+
+
+
+ {Schedule.description(
+ @form[:schedule].value || "0 8 * * *",
+ @form[:timezone].value || "Etc/UTC"
+ )}
+
+
+
Next three runs
+
+ -
+ {Calendar.strftime(run, "%a, %b %-d, %Y at %-I:%M %p %Z")}
+
+
+
+ Enter a valid schedule and time zone to see upcoming runs.
+
+
+
+
+ At a daylight saving change, a skipped clock time does not run. A repeated daily, weekday, weekly, or monthly time runs once.
+
+
+ """
+ end
+end
diff --git a/lib/query_canary_web/live/check_live/form.ex b/lib/query_canary_web/live/check_live/form.ex
index fea4cd5..0117634 100644
--- a/lib/query_canary_web/live/check_live/form.ex
+++ b/lib/query_canary_web/live/check_live/form.ex
@@ -4,7 +4,9 @@ defmodule QueryCanaryWeb.CheckLive.Form do
alias QueryCanary.Checks
alias QueryCanary.Checks.Check
alias QueryCanaryWeb.NotificationComponents
+ alias QueryCanaryWeb.ScheduleForm
import QueryCanaryWeb.NotificationComponents, only: [notification_fields: 1]
+ import QueryCanaryWeb.Components.SchedulePicker, only: [schedule_picker: 1]
on_mount {QueryCanaryWeb.CheckAuth, :edit}
@@ -21,7 +23,12 @@ defmodule QueryCanaryWeb.CheckLive.Form do
<.input field={@form[:name]} type="text" label="Name" />
<.input field={@form[:enabled]} type="checkbox" label="Enabled" />
<.input field={@form[:public]} type="checkbox" label="Publicly Viewable?" />
- <.input field={@form[:schedule]} type="text" label="Cron Schedule" />
+ <.schedule_picker
+ form={@form}
+ ui={@schedule_ui}
+ next_runs={@next_runs}
+ auto_timezone={@auto_timezone}
+ />
<.live_component
module={QueryCanaryWeb.Components.SQLEditor}
id="check-sql-editor"
@@ -63,6 +70,9 @@ defmodule QueryCanaryWeb.CheckLive.Form do
socket
|> assign(:page_title, "Edit Check")
|> assign(:check, check)
+ |> assign(:schedule_ui, ScheduleForm.initial_ui(check))
+ |> assign(:next_runs, QueryCanary.Checks.Schedule.next_runs(check.schedule, check.timezone))
+ |> assign(:auto_timezone, false)
|> assign(
:notification_settings,
NotificationComponents.settings(
@@ -75,24 +85,76 @@ defmodule QueryCanaryWeb.CheckLive.Form do
end
defp apply_action(socket, :new, _params) do
- check = %Check{user_id: socket.assigns.current_scope.user.id}
+ check = %Check{user_id: socket.assigns.current_scope.user.id, schedule: "0 8 * * *"}
socket
|> assign(:page_title, "New Check")
|> assign(:check, check)
+ |> assign(:schedule_ui, ScheduleForm.initial_ui(check))
+ |> assign(:next_runs, QueryCanary.Checks.Schedule.next_runs(check.schedule, check.timezone))
+ |> assign(:auto_timezone, true)
|> assign(:form, to_form(Checks.change_check(socket.assigns.current_scope, check)))
end
@impl true
- def handle_event("validate", %{"check" => check_params}, socket) do
+ def handle_event("validate", %{"check" => check_params} = params, socket) do
+ {check_params, ui, error} =
+ ScheduleForm.prepare(
+ check_params,
+ params["schedule_ui"],
+ socket.assigns.schedule_ui,
+ socket.assigns.form[:schedule].value
+ )
+
changeset =
Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params)
+ |> ScheduleForm.add_schedule_error(error)
+
+ {:noreply,
+ assign(socket,
+ form: to_form(changeset, action: :validate),
+ schedule_ui: ui,
+ next_runs: ScheduleForm.next_runs(changeset),
+ auto_timezone: false
+ )}
+ end
+
+ def handle_event("save", %{"check" => check_params} = params, socket) do
+ {check_params, ui, error} =
+ ScheduleForm.prepare(
+ check_params,
+ params["schedule_ui"],
+ socket.assigns.schedule_ui,
+ socket.assigns.form[:schedule].value
+ )
- {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
+ socket = assign(socket, schedule_ui: ui, auto_timezone: false)
+
+ if error do
+ changeset =
+ Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params)
+ |> ScheduleForm.add_schedule_error(error)
+
+ {:noreply, assign(socket, form: to_form(changeset), next_runs: [])}
+ else
+ save_check(socket, socket.assigns.live_action, check_params)
+ end
end
- def handle_event("save", %{"check" => check_params}, socket) do
- save_check(socket, socket.assigns.live_action, check_params)
+ def handle_event("detect_timezone", %{"timezone" => timezone}, socket) do
+ if socket.assigns.auto_timezone and
+ match?({:ok, _}, DateTime.shift_zone(DateTime.utc_now(), timezone)) do
+ changeset = Ecto.Changeset.put_change(socket.assigns.form.source, :timezone, timezone)
+
+ {:noreply,
+ assign(socket,
+ form: to_form(changeset),
+ next_runs: ScheduleForm.next_runs(changeset),
+ auto_timezone: false
+ )}
+ else
+ {:noreply, socket}
+ end
end
defp save_check(socket, :edit, check_params) do
@@ -106,7 +168,8 @@ defmodule QueryCanaryWeb.CheckLive.Form do
)}
{:error, %Ecto.Changeset{} = changeset} ->
- {:noreply, assign(socket, form: to_form(changeset))}
+ {:noreply,
+ assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))}
end
end
@@ -121,7 +184,8 @@ defmodule QueryCanaryWeb.CheckLive.Form do
)}
{:error, %Ecto.Changeset{} = changeset} ->
- {:noreply, assign(socket, form: to_form(changeset))}
+ {:noreply,
+ assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))}
end
end
diff --git a/lib/query_canary_web/live/check_live/show.ex b/lib/query_canary_web/live/check_live/show.ex
index e77a07d..7ebe488 100644
--- a/lib/query_canary_web/live/check_live/show.ex
+++ b/lib/query_canary_web/live/check_live/show.ex
@@ -3,6 +3,7 @@ defmodule QueryCanaryWeb.CheckLive.Show do
alias QueryCanary.Checks
alias QueryCanary.Checks.{ChartData, CheckResult}
+ alias QueryCanary.Checks.Schedule
import QueryCanaryWeb.Components.CheckAnalysis
@@ -18,7 +19,10 @@ defmodule QueryCanaryWeb.CheckLive.Show do
{@check.name}
<:subtitle>
- Last run: {@last_run} • Next run: {@next_run} • Schedule: {@check.schedule}
+ Last run: {@last_run} • Next run: {@next_run} • {Schedule.description(
+ @check.schedule,
+ @check.timezone || "Etc/UTC"
+ )}
<:actions>
<.button
@@ -119,15 +123,21 @@ defmodule QueryCanaryWeb.CheckLive.Show do
else: hd(recent_results)
last_run =
- if Enum.empty?(recent_results),
- do: "No previous run",
- else:
- hd(recent_results) |> Map.get(:inserted_at) |> Calendar.strftime("%Y-%m-%d %H:%M:%S")
+ if Enum.empty?(recent_results) do
+ "No previous run"
+ else
+ recent_results
+ |> hd()
+ |> Map.get(:inserted_at)
+ |> DateTime.shift_zone!(check.timezone || "Etc/UTC")
+ |> Calendar.strftime("%Y-%m-%d %H:%M:%S %Z")
+ end
next_run =
- Crontab.CronExpression.Parser.parse!(check.schedule)
- |> Crontab.Scheduler.get_next_run_date!()
- |> Calendar.strftime("%Y-%m-%d %H:%M:%S")
+ case Schedule.next_runs(check.schedule, check.timezone || "Etc/UTC", DateTime.utc_now(), 1) do
+ [run] -> Calendar.strftime(run, "%Y-%m-%d %H:%M:%S %Z")
+ [] -> "Unavailable"
+ end
{:ok,
socket
diff --git a/lib/query_canary_web/live/quickstart/check_live.ex b/lib/query_canary_web/live/quickstart/check_live.ex
index e88c8df..0062d5b 100644
--- a/lib/query_canary_web/live/quickstart/check_live.ex
+++ b/lib/query_canary_web/live/quickstart/check_live.ex
@@ -1,13 +1,13 @@
defmodule QueryCanaryWeb.Quickstart.CheckLive do
use QueryCanaryWeb, :live_view
- import Crontab.CronExpression
-
alias QueryCanary.Servers
alias QueryCanary.Checks
alias QueryCanary.Checks.Check
+ alias QueryCanaryWeb.ScheduleForm
alias QueryCanaryWeb.NotificationComponents
import QueryCanaryWeb.NotificationComponents, only: [notification_fields: 1]
+ import QueryCanaryWeb.Components.SchedulePicker, only: [schedule_picker: 1]
def render(assigns) do
~H"""
@@ -42,25 +42,12 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
/>
-
-
-
- <.input
- field={@form[:schedule]}
- type="text"
- value={@form[:schedule].value || "0 8 * * *"}
- />
-
-
-
-
-
- -
- {Calendar.strftime(d, "%Y-%m-%d %H:%M:%S")}
-
-
-
-
+ <.schedule_picker
+ form={@form}
+ ui={@schedule_ui}
+ next_runs={@next_runs}
+ auto_timezone={@auto_timezone}
+ />
<.notification_fields
form={@form}
settings={@notification_settings}
@@ -91,9 +78,12 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
def mount(%{"server_id" => server_id}, _session, socket) do
server = Servers.get_server!(socket.assigns.current_scope, server_id)
- check = %Check{user_id: socket.assigns.current_scope.user.id, server_id: server.id}
- # CronExpression.Parser.parse!("0 8 * * *")
+ check = %Check{
+ user_id: socket.assigns.current_scope.user.id,
+ server_id: server.id,
+ schedule: "0 8 * * *"
+ }
{:ok,
socket
@@ -104,10 +94,9 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
NotificationComponents.settings(socket.assigns.current_scope, server, connected?(socket))
)
|> assign(:result, nil)
- |> assign(
- :next_schedule,
- Enum.take(Crontab.Scheduler.get_next_run_dates(~e[0 8 * * *]), 3)
- )
+ |> assign(:schedule_ui, ScheduleForm.initial_ui(check))
+ |> assign(:next_runs, Checks.Schedule.next_runs(check.schedule, check.timezone))
+ |> assign(:auto_timezone, true)
|> assign(
:form,
to_form(Checks.change_check(socket.assigns.current_scope, check))
@@ -120,6 +109,9 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
{:noreply,
socket
|> assign(:check, check)
+ |> assign(:schedule_ui, ScheduleForm.initial_ui(check))
+ |> assign(:next_runs, Checks.Schedule.next_runs(check.schedule, check.timezone))
+ |> assign(:auto_timezone, false)
|> assign(
:form,
to_form(Checks.change_check(socket.assigns.current_scope, check))
@@ -137,21 +129,72 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
{:noreply, socket}
end
- def handle_event("validate", %{"check" => check_params}, socket) do
+ def handle_event("validate", %{"check" => check_params} = params, socket) do
+ {check_params, ui, error} =
+ ScheduleForm.prepare(
+ check_params,
+ params["schedule_ui"],
+ socket.assigns.schedule_ui,
+ socket.assigns.form[:schedule].value
+ )
+
changeset =
Checks.change_check(
socket.assigns.current_scope,
socket.assigns.check,
check_params
)
+ |> ScheduleForm.add_schedule_error(error)
{:noreply,
socket
- |> maybe_put_next_schedule(changeset)
- |> assign(form: to_form(changeset, action: :validate))}
+ |> assign(
+ form: to_form(changeset, action: :validate),
+ schedule_ui: ui,
+ next_runs: ScheduleForm.next_runs(changeset),
+ auto_timezone: false
+ )}
end
- def handle_event("save", %{"check" => check_params}, socket) do
+ def handle_event("save", %{"check" => check_params} = params, socket) do
+ {check_params, ui, error} =
+ ScheduleForm.prepare(
+ check_params,
+ params["schedule_ui"],
+ socket.assigns.schedule_ui,
+ socket.assigns.form[:schedule].value
+ )
+
+ socket = assign(socket, schedule_ui: ui, auto_timezone: false)
+
+ if error do
+ changeset =
+ Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params)
+ |> ScheduleForm.add_schedule_error(error)
+
+ {:noreply, assign(socket, form: to_form(changeset), next_runs: [])}
+ else
+ save_check(socket, check_params)
+ end
+ end
+
+ def handle_event("detect_timezone", %{"timezone" => timezone}, socket) do
+ if socket.assigns.auto_timezone and
+ match?({:ok, _}, DateTime.shift_zone(DateTime.utc_now(), timezone)) do
+ changeset = Ecto.Changeset.put_change(socket.assigns.form.source, :timezone, timezone)
+
+ {:noreply,
+ assign(socket,
+ form: to_form(changeset),
+ next_runs: ScheduleForm.next_runs(changeset),
+ auto_timezone: false
+ )}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp save_check(socket, check_params) do
current_scope = socket.assigns.current_scope
check =
@@ -198,24 +241,8 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
end
{:error, %Ecto.Changeset{} = changeset} ->
- {:noreply, assign(socket, form: to_form(changeset))}
- end
- end
-
- def maybe_put_next_schedule(socket, changeset) do
- schedule = Ecto.Changeset.get_change(changeset, :schedule)
-
- try do
- next_schedule =
- case Crontab.CronExpression.Parser.parse(schedule || "") do
- {:ok, exp} -> Enum.take(Crontab.Scheduler.get_next_run_dates(exp), 3)
- _ -> socket.assigns.next_schedule
- end
-
- socket |> assign(:next_schedule, next_schedule)
- catch
- _e ->
- socket.assigns.next_schedule
+ {:noreply,
+ assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))}
end
end
diff --git a/lib/query_canary_web/schedule_form.ex b/lib/query_canary_web/schedule_form.ex
new file mode 100644
index 0000000..bdc0827
--- /dev/null
+++ b/lib/query_canary_web/schedule_form.ex
@@ -0,0 +1,43 @@
+defmodule QueryCanaryWeb.ScheduleForm do
+ @moduledoc false
+
+ alias QueryCanary.Checks.Schedule
+
+ def initial_ui(check), do: Schedule.from_cron(check.schedule)
+
+ def prepare(check_params, ui_params, current_ui, current_cron) do
+ ui_params = ui_params || %{}
+ ui = Map.merge(current_ui, Map.take(ui_params, ~w(kind time weekday monthday)))
+ raw_cron = Map.get(check_params, "schedule", current_cron)
+
+ cond do
+ ui != current_ui and ui["kind"] != "custom" ->
+ case Schedule.to_cron(ui) do
+ {:ok, cron} -> {Map.put(check_params, "schedule", cron), ui, nil}
+ {:error, error} -> {Map.put(check_params, "schedule", ""), ui, error}
+ end
+
+ ui != current_ui and ui["kind"] == "custom" ->
+ {Map.put(check_params, "schedule", raw_cron), ui, nil}
+
+ raw_cron != current_cron ->
+ next_ui = if ui["kind"] == "custom", do: ui, else: Schedule.from_cron(raw_cron)
+ {check_params, next_ui, nil}
+
+ true ->
+ {check_params, ui, nil}
+ end
+ end
+
+ def add_schedule_error(changeset, nil), do: changeset
+
+ def add_schedule_error(changeset, error) do
+ Ecto.Changeset.add_error(changeset, :schedule, error)
+ end
+
+ def next_runs(changeset) do
+ cron = Ecto.Changeset.get_field(changeset, :schedule)
+ timezone = Ecto.Changeset.get_field(changeset, :timezone)
+ Schedule.next_runs(cron || "", timezone || "Etc/UTC")
+ end
+end
diff --git a/priv/docs/overview.md b/priv/docs/overview.md
index e75887d..4a7cd0e 100644
--- a/priv/docs/overview.md
+++ b/priv/docs/overview.md
@@ -56,7 +56,7 @@ WHERE created_at >= CURRENT_DATE - INTERVAL '1 day';
```
### 3. Set a schedule
-QueryCanary supports a full crontab syntax, so you can customize check intervals to pretty much anything you can imagine.
+Choose a frequency such as every 15 minutes, daily, weekdays, weekly, or monthly. For calendar schedules, choose a time and time zone. The form previews the next three runs before you save. Custom five-field cron expressions are available under **Custom cron**. Existing cron schedules remain editable.
### 4. Get alerted
When QueryCanary detects a change in your data, it'll send you or your team an email alert.
@@ -230,4 +230,4 @@ WHERE stock_quantity < 5;
- [Quickstart](https://querycanary.com/quickstart)
-Need help? support@querycanary.com or join our [Discord](https://discord.gg/Y6UMkgWXue)
\ No newline at end of file
+Need help? support@querycanary.com or join our [Discord](https://discord.gg/Y6UMkgWXue)
diff --git a/priv/repo/migrations/20260925000200_add_check_timezone.exs b/priv/repo/migrations/20260925000200_add_check_timezone.exs
new file mode 100644
index 0000000..f1c07fa
--- /dev/null
+++ b/priv/repo/migrations/20260925000200_add_check_timezone.exs
@@ -0,0 +1,9 @@
+defmodule QueryCanary.Repo.Migrations.AddCheckTimezone do
+ use Ecto.Migration
+
+ def change do
+ alter table(:checks) do
+ add :timezone, :string, null: false, default: "Etc/UTC"
+ end
+ end
+end
diff --git a/test/query_canary/checks/schedule_test.exs b/test/query_canary/checks/schedule_test.exs
new file mode 100644
index 0000000..a13f22f
--- /dev/null
+++ b/test/query_canary/checks/schedule_test.exs
@@ -0,0 +1,66 @@
+defmodule QueryCanary.Checks.ScheduleTest do
+ use ExUnit.Case, async: true
+
+ alias QueryCanary.Checks.Schedule
+
+ test "common schedules round trip through cron" do
+ for {kind, expected} <- [
+ {"minute", "* * * * *"},
+ {"5_minutes", "*/5 * * * *"},
+ {"hourly", "0 * * * *"},
+ {"daily", "30 9 * * *"},
+ {"weekdays", "30 9 * * 1-5"},
+ {"weekly", "30 9 * * 2"},
+ {"monthly", "30 9 1 * *"}
+ ] do
+ ui = %{"kind" => kind, "time" => "09:30", "weekday" => "2", "monthday" => "1"}
+ assert {:ok, ^expected} = Schedule.to_cron(ui)
+ assert Schedule.from_cron(expected)["kind"] == kind
+ end
+
+ assert Schedule.from_cron("7 13 * * 2,4")["kind"] == "custom"
+
+ assert {:error, _} =
+ Schedule.to_cron(%{"kind" => "monthly", "time" => "09:30", "monthday" => "31"})
+ end
+
+ test "daily local time follows summer and winter offsets" do
+ cron = "0 9 * * *"
+ zone = "America/New_York"
+
+ assert Schedule.matches?(cron, zone, ~U[2026-01-15 14:00:00Z])
+ refute Schedule.matches?(cron, zone, ~U[2026-01-15 13:00:00Z])
+ assert Schedule.matches?(cron, zone, ~U[2026-07-15 13:00:00Z])
+ refute Schedule.matches?(cron, zone, ~U[2026-07-15 14:00:00Z])
+ end
+
+ test "a skipped local time does not appear in the preview" do
+ [first, second, third] =
+ Schedule.next_runs("30 2 * * *", "America/New_York", ~U[2026-03-08 00:00:00Z])
+
+ assert DateTime.shift_zone!(first, "Etc/UTC") == ~U[2026-03-09 06:30:00Z]
+ assert DateTime.shift_zone!(second, "Etc/UTC") == ~U[2026-03-10 06:30:00Z]
+ assert DateTime.shift_zone!(third, "Etc/UTC") == ~U[2026-03-11 06:30:00Z]
+ end
+
+ test "a repeated daily time runs once, while intervals keep running" do
+ zone = "America/New_York"
+ first = ~U[2026-11-01 05:30:00Z]
+ second = ~U[2026-11-01 06:30:00Z]
+
+ assert Schedule.matches?("30 1 * * *", zone, first)
+ refute Schedule.matches?("30 1 * * *", zone, second)
+ assert Schedule.matches?("*/30 * * * *", zone, first)
+ assert Schedule.matches?("*/30 * * * *", zone, second)
+
+ [next, later | _] = Schedule.next_runs("30 1 * * *", zone, ~U[2026-11-01 04:00:00Z])
+ assert DateTime.shift_zone!(next, "Etc/UTC") == first
+ assert DateTime.shift_zone!(later, "Etc/UTC") == ~U[2026-11-02 06:30:00Z]
+
+ [interval_first, interval_second | _] =
+ Schedule.next_runs("* * * * *", zone, ~U[2026-11-01 05:29:00Z])
+
+ assert DateTime.shift_zone!(interval_first, "Etc/UTC") == ~U[2026-11-01 05:30:00Z]
+ assert DateTime.shift_zone!(interval_second, "Etc/UTC") == ~U[2026-11-01 05:31:00Z]
+ end
+end
diff --git a/test/query_canary_web/live/check_live_test.exs b/test/query_canary_web/live/check_live_test.exs
index d1e8db9..62746e6 100644
--- a/test/query_canary_web/live/check_live_test.exs
+++ b/test/query_canary_web/live/check_live_test.exs
@@ -33,10 +33,9 @@ defmodule QueryCanaryWeb.CheckLiveTest do
@update_attrs %{
name: "Updated Check",
- enabled: false,
- schedule: "0 0 * * *"
+ enabled: false
}
- @invalid_attrs %{name: "", schedule: ""}
+ @invalid_attrs %{name: ""}
setup :register_and_log_in_user
@@ -184,6 +183,47 @@ defmodule QueryCanaryWeb.CheckLiveTest do
end
end
+ describe "Scheduling" do
+ setup [:create_check]
+
+ test "sets a weekday time and timezone without typing cron", %{
+ conn: conn,
+ check: check,
+ scope: scope
+ } do
+ {:ok, view, _} = live(conn, ~p"/checks/#{check}/edit")
+
+ view
+ |> form("#check-form", schedule_ui: %{kind: "weekdays"})
+ |> render_change()
+
+ html =
+ view
+ |> form("#check-form",
+ schedule_ui: %{time: "09:30"},
+ check: %{timezone: "America/New_York"}
+ )
+ |> render_change()
+
+ assert html =~ "Weekdays at 9:30 AM (America/New_York)"
+ assert html =~ "Next three runs"
+
+ view |> form("#check-form") |> render_submit()
+ saved = Checks.get_check!(scope, check.id)
+ assert saved.schedule == "30 9 * * 1-5"
+ assert saved.timezone == "America/New_York"
+ end
+
+ test "keeps an unusual cron expression editable", %{conn: conn, scope: scope, check: check} do
+ {:ok, check} = Checks.update_check(scope, check, %{schedule: "7 13 * * 2,4"})
+ {:ok, view, _} = live(conn, ~p"/checks/#{check}/edit")
+ assert has_element?(view, "#schedule-kind option[selected][value='custom']")
+
+ view |> form("#check-form", check: %{schedule: "5 9 * * 1-5"}) |> render_submit()
+ assert Checks.get_check!(scope, check.id).schedule == "5 9 * * 1-5"
+ end
+ end
+
describe "Permissions" do
test "public viewers cannot rerun a check even with a forged event", %{conn: conn} do
check = check_fixture(user_scope_fixture(), %{public: true})
diff --git a/test/query_canary_web/live/quickstart_schedule_test.exs b/test/query_canary_web/live/quickstart_schedule_test.exs
new file mode 100644
index 0000000..a90a23e
--- /dev/null
+++ b/test/query_canary_web/live/quickstart_schedule_test.exs
@@ -0,0 +1,26 @@
+defmodule QueryCanaryWeb.QuickstartScheduleTest do
+ use QueryCanaryWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import QueryCanary.ServersFixtures
+
+ setup :register_and_log_in_user
+
+ test "quickstart previews a local daily schedule", %{conn: conn, scope: scope} do
+ server = server_fixture(scope)
+ {:ok, view, _} = live(conn, ~p"/quickstart/check?server_id=#{server.id}")
+
+ assert has_element?(view, "#schedule-kind option[selected][value='daily']")
+
+ html =
+ view
+ |> form("#check-form",
+ schedule_ui: %{time: "14:15"},
+ check: %{timezone: "Europe/London"}
+ )
+ |> render_change()
+
+ assert html =~ "Every day at 2:15 PM (Europe/London)"
+ assert html =~ "Next three runs"
+ end
+end