diff --git a/__tests__/components/DetailViewHiddenFields.test.tsx b/__tests__/components/DetailViewHiddenFields.test.tsx
new file mode 100644
index 0000000..ef3b0b5
--- /dev/null
+++ b/__tests__/components/DetailViewHiddenFields.test.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { DetailViewRenderer } from "~/components/renderers/DetailViewRenderer";
+import type { FieldDefinition } from "~/components/renderers/types";
+
+/**
+ * The detail screen falls back to auto-laying-out a record's keys when there is
+ * no curated form view. That fallback must mirror the form's `isEntryField`
+ * filtering so internal plumbing (multi-tenancy keys the server injects) and
+ * metadata-hidden fields never leak into the layout — see the P0 UX fix where
+ * `Organization Id` was leading the task detail.
+ */
+describe("DetailViewRenderer — fallback field filtering", () => {
+ const record = {
+ id: "rec_1",
+ subject: "Learn ObjectStack",
+ status: "completed",
+ organization_id: "org_123",
+ secret_token: "shhh",
+ created_at: "2026-01-01T00:00:00.000Z",
+ };
+
+ const fields: FieldDefinition[] = [
+ { name: "subject", label: "Subject", type: "text" },
+ { name: "status", label: "Status", type: "text" },
+ // Declared but hidden by metadata — must be filtered like the form does.
+ { name: "secret_token", label: "Secret Token", type: "text", hidden: true },
+ ];
+
+ it("drops injected tenancy fields and metadata-hidden fields, keeps business fields", () => {
+ const { queryByText, getByText } = render(
+ ,
+ );
+
+ // Business fields remain.
+ expect(getByText("Subject")).toBeTruthy();
+ expect(getByText("Status")).toBeTruthy();
+
+ // Internal tenancy plumbing never surfaces.
+ expect(queryByText("Organization Id")).toBeNull();
+ expect(queryByText(/organization/i)).toBeNull();
+
+ // A field the metadata marks hidden is filtered out of the fallback too.
+ expect(queryByText("Secret Token")).toBeNull();
+ });
+});
diff --git a/__tests__/hooks/useDashboardData.test.ts b/__tests__/hooks/useDashboardData.test.ts
index 0626d69..e7dc91d 100644
--- a/__tests__/hooks/useDashboardData.test.ts
+++ b/__tests__/hooks/useDashboardData.test.ts
@@ -11,8 +11,11 @@ jest.mock("@objectstack/client-react", () => ({
useQuery: (...args: unknown[]) => mockUseQuery(...args),
}));
-import { useWidgetQuery } from "~/hooks/useDashboardData";
-import type { DashboardWidgetMeta } from "~/components/renderers/types";
+import { resolveDatasetWidget, useWidgetQuery } from "~/hooks/useDashboardData";
+import type {
+ DashboardWidgetMeta,
+ DatasetMeta,
+} from "~/components/renderers/types";
beforeEach(() => {
mockUseQuery.mockReset();
@@ -152,4 +155,96 @@ describe("useWidgetQuery", () => {
const { result: maxResult } = renderHook(() => useWidgetQuery(maxWidget));
expect(maxResult.current.value).toBe(50);
});
+
+ it("returns a terminal empty state (never loading) when there is no object", () => {
+ // A dataset widget whose metadata failed to resolve leaves `object`
+ // undefined; `useQuery` is disabled and its `isLoading` stays true forever,
+ // so the hook must short-circuit rather than spin.
+ mockUseQuery.mockReturnValue({ data: null, isLoading: true });
+ const widget: DashboardWidgetMeta = { name: "orphan", type: "metric" };
+ const { result } = renderHook(() => useWidgetQuery(widget));
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.value).toBeUndefined();
+ });
+
+ it("counts rows per bucket for a count-aggregate chart (no valueField)", () => {
+ // The common dataset case: a `count` measure has no source field, so chart
+ // buckets must count rows — not aggregate an absent value field (→ all 0).
+ mockUseQuery.mockReturnValue({
+ data: {
+ records: [
+ { id: "1", status: "open" },
+ { id: "2", status: "open" },
+ { id: "3", status: "done" },
+ ],
+ },
+ isLoading: false,
+ });
+ const widget: DashboardWidgetMeta = {
+ name: "by_status",
+ object: "tasks",
+ type: "bar",
+ aggregate: "count",
+ categoryField: "status",
+ };
+ const { result } = renderHook(() => useWidgetQuery(widget));
+ const series = result.current.chartData ?? [];
+ const open = series.find((p) => p.label === "open");
+ const done = series.find((p) => p.label === "done");
+ expect(open?.value).toBe(2);
+ expect(done?.value).toBe(1);
+ });
+});
+
+describe("resolveDatasetWidget", () => {
+ const dataset: DatasetMeta = {
+ name: "task_metrics",
+ object: "todo_task",
+ dimensions: [{ name: "status", field: "status", type: "string" }],
+ measures: [
+ { name: "task_count", aggregate: "count" },
+ { name: "est_hours", aggregate: "sum", field: "estimated_hours", format: "0.0" },
+ ],
+ };
+
+ it("passes a non-dataset widget through unchanged", () => {
+ const widget: DashboardWidgetMeta = { name: "w", object: "tasks", type: "metric" };
+ expect(resolveDatasetWidget(widget, undefined)).toBe(widget);
+ });
+
+ it("resolves a count-measure metric to the base object", () => {
+ const widget: DashboardWidgetMeta = {
+ name: "total",
+ type: "metric",
+ dataset: "task_metrics",
+ values: ["task_count"],
+ layout: { w: 3 },
+ options: { color: "#3B82F6" },
+ };
+ const resolved = resolveDatasetWidget(widget, dataset);
+ expect(resolved.object).toBe("todo_task");
+ expect(resolved.aggregate).toBe("count");
+ // A count measure has no source field — counts rows instead.
+ expect(resolved.valueField).toBeUndefined();
+ expect(resolved.span).toBe(1);
+ expect(resolved.chartConfig?.colors).toEqual(["#3B82F6"]);
+ });
+
+ it("resolves a sum measure + dimension and maps a wide layout to span 2", () => {
+ const widget: DashboardWidgetMeta = {
+ name: "hours_by_status",
+ type: "bar",
+ dataset: "task_metrics",
+ values: ["est_hours"],
+ dimensions: ["status"],
+ layout: { w: 8 },
+ };
+ const resolved = resolveDatasetWidget(widget, dataset);
+ expect(resolved.object).toBe("todo_task");
+ expect(resolved.aggregate).toBe("sum");
+ expect(resolved.valueField).toBe("estimated_hours");
+ expect(resolved.categoryField).toBe("status");
+ expect(resolved.span).toBe(2);
+ expect(resolved.chartConfig?.format).toBe("0.0");
+ });
});
diff --git a/__tests__/lib/query-builder.test.ts b/__tests__/lib/query-builder.test.ts
index f00ce5e..f9a2dcf 100644
--- a/__tests__/lib/query-builder.test.ts
+++ b/__tests__/lib/query-builder.test.ts
@@ -8,6 +8,7 @@ import {
serializeFilterTree,
buildProjection,
OPERATOR_META,
+ resolveFilterMacro,
type FilterOperator,
} from "~/lib/query-builder";
@@ -206,3 +207,30 @@ describe("OPERATOR_META", () => {
});
});
});
+
+describe("resolveFilterMacro — week tokens", () => {
+ const DAY = 86_400_000;
+
+ it("resolves {current_week_start} to the most recent Monday, start of day", () => {
+ const v = resolveFilterMacro("{current_week_start}");
+ expect(typeof v).toBe("number");
+ const d = new Date(v as number);
+ expect(d.getDay()).toBe(1); // Monday
+ expect([d.getHours(), d.getMinutes(), d.getSeconds()]).toEqual([0, 0, 0]);
+ expect(v as number).toBeLessThanOrEqual(Date.now());
+ });
+
+ it("resolves {N_weeks_ago} and {last_N_weeks} to N*7 days before now", () => {
+ const before = Date.now();
+ const ago = resolveFilterMacro("{4_weeks_ago}") as number;
+ const after = Date.now();
+ // Within the window [before - 28d, after - 28d].
+ expect(ago).toBeGreaterThanOrEqual(before - 28 * DAY - 5);
+ expect(ago).toBeLessThanOrEqual(after - 28 * DAY + 5);
+ expect(typeof resolveFilterMacro("{last_2_weeks}")).toBe("number");
+ });
+
+ it("leaves an unknown macro untouched (visibly inert, not silently zero)", () => {
+ expect(resolveFilterMacro("{not_a_real_macro}")).toBe("{not_a_real_macro}");
+ });
+});
diff --git a/app/(app)/[appName]/dashboard/[dashboardName].tsx b/app/(app)/[appName]/dashboard/[dashboardName].tsx
index cfe3918..4e58869 100644
--- a/app/(app)/[appName]/dashboard/[dashboardName].tsx
+++ b/app/(app)/[appName]/dashboard/[dashboardName].tsx
@@ -4,9 +4,13 @@ import { useLocalSearchParams } from "expo-router";
import { useClient } from "@objectstack/client-react";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { DashboardViewRenderer } from "~/components/renderers";
-import type { DashboardMeta, DashboardWidgetMeta } from "~/components/renderers";
+import type {
+ DashboardMeta,
+ DashboardWidgetMeta,
+ DatasetMeta,
+} from "~/components/renderers";
import type { WidgetDataPayload } from "~/components/renderers";
-import { useWidgetQuery } from "~/hooks/useDashboardData";
+import { resolveDatasetWidget, useWidgetQuery } from "~/hooks/useDashboardData";
/* ------------------------------------------------------------------ */
/* Widget data fetcher (calls hook per-widget, reports via callback) */
@@ -59,12 +63,42 @@ export default function DashboardScreen() {
};
// Spec dashboards key each widget by `id`; the renderer/data-fetcher key
// off `name`, so normalize once here.
+ const widgets = (raw.widgets ?? []).map((w) => ({
+ ...w,
+ name: w.name ?? w.id ?? "",
+ }));
+
+ // 8.0-spec widgets reference an analytics `dataset` instead of a raw
+ // `object`. Fetch each distinct dataset's metadata (an analytics view
+ // over a base object) once, then resolve every widget into the
+ // object-query shape the data hook understands.
+ const datasetNames = [
+ ...new Set(
+ widgets
+ .map((w) => w.dataset)
+ .filter((d): d is string => typeof d === "string" && d.length > 0),
+ ),
+ ];
+ const datasets = new Map();
+ await Promise.all(
+ datasetNames.map(async (name) => {
+ try {
+ const ds = (await client.meta.getItem("dataset", name)) as
+ | (DatasetMeta & { dataset?: DatasetMeta })
+ | undefined;
+ const resolved = ds?.dataset ?? ds;
+ if (resolved?.object) datasets.set(name, resolved);
+ } catch {
+ // Dataset metadata unavailable — widget falls back to empty state.
+ }
+ }),
+ );
+
const meta: DashboardMeta = {
...raw,
- widgets: (raw.widgets ?? []).map((w) => ({
- ...w,
- name: w.name ?? w.id ?? "",
- })),
+ widgets: widgets.map((w) =>
+ resolveDatasetWidget(w, w.dataset ? datasets.get(w.dataset) : undefined),
+ ),
};
setDashboard(meta);
} catch {
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 95bebd2..44a5984 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -2,6 +2,7 @@ import "../global.css";
import "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation()
import { useCallback, useEffect, useMemo } from "react";
+import { ActivityIndicator, View } from "react-native";
import { Stack, useRouter, useSegments } from "expo-router";
import { StatusBar } from "expo-status-bar";
import * as Linking from "expo-linking";
@@ -62,17 +63,15 @@ function useProtectedRoute(serverUrl: string | null, isReady: boolean) {
}, [session, isPending, segments, serverUrl, isReady, router]);
}
-export default function RootLayout() {
- const serverUrl = useServerStore((s) => s.serverUrl);
- const isReady = useServerStore((s) => s.isReady);
- const hydrate = useServerStore((s) => s.hydrate);
-
- // On mount, load the persisted server URL and reinitialize clients
- useEffect(() => {
- void hydrate();
- }, [hydrate]);
-
- useProtectedRoute(serverUrl, isReady);
+/**
+ * The signed-in app shell. Mounted only after `hydrate()` has re-targeted the
+ * auth/data clients at the persisted server URL — so the very first
+ * `useSession()` (and every screen data hook) hits the configured server, not
+ * the default API host. Mounting any of this earlier fires a storm of
+ * connection-refused requests (and a dev-overlay error) on every cold start.
+ */
+function AppShell({ serverUrl }: { serverUrl: string | null }) {
+ useProtectedRoute(serverUrl, true);
const { data: session } = authClient.useSession();
const sessionRecord = session as Record | null;
@@ -101,22 +100,43 @@ export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
+
+export default function RootLayout() {
+ const serverUrl = useServerStore((s) => s.serverUrl);
+ const isReady = useServerStore((s) => s.isReady);
+ const hydrate = useServerStore((s) => s.hydrate);
+
+ // On mount, load the persisted server URL and reinitialize clients
+ useEffect(() => {
+ void hydrate();
+ }, [hydrate]);
+
+ return (
+
+
+
+
+ {isReady ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx
index 2d167a8..10c90e0 100644
--- a/components/renderers/DetailViewRenderer.tsx
+++ b/components/renderers/DetailViewRenderer.tsx
@@ -135,6 +135,14 @@ const SYSTEM_FIELDS = new Set([
"last_modified_by",
]);
+/**
+ * Internal plumbing fields the server injects onto every record (multi-tenancy
+ * / sharding keys). They aren't part of the object's declared fields, carry no
+ * business meaning, and must never surface in the auto-layout — unlike the
+ * audit fields above, they don't even belong in "System Information".
+ */
+const INTERNAL_FIELDS = new Set(["organization_id", "tenant_id", "space_id"]);
+
/* ------------------------------------------------------------------ */
/* Action Bar */
/* ------------------------------------------------------------------ */
@@ -458,8 +466,26 @@ export function DetailViewRenderer({
// Fallback: auto-layout, business fields first then a trailing "System
// Information" section for audit fields.
+ // Fields whose object metadata marks them hidden/system — the curated form
+ // view filters these via `isEntryField`; the fallback must match so a
+ // hidden field never leaks into the detail layout.
+ const hiddenByMeta = new Set(
+ fields
+ .filter((f) => {
+ const flag = (k: string) => (f as Record)[k] === true;
+ return flag("hidden") || flag("system");
+ })
+ .map((f) => f.name),
+ );
+
const buildSections = (allKeys: string[]): FormSection[] => {
- const keys = allKeys.filter((k) => !k.startsWith("_") && k !== "id");
+ const keys = allKeys.filter(
+ (k) =>
+ !k.startsWith("_") &&
+ k !== "id" &&
+ !INTERNAL_FIELDS.has(k) &&
+ !hiddenByMeta.has(k),
+ );
const business = keys.filter((k) => !SYSTEM_FIELDS.has(k));
const system = keys.filter((k) => SYSTEM_FIELDS.has(k));
const result: FormSection[] = [];
diff --git a/components/renderers/index.ts b/components/renderers/index.ts
index 30e0a5b..0ef4a84 100644
--- a/components/renderers/index.ts
+++ b/components/renderers/index.ts
@@ -73,6 +73,9 @@ export type {
FormViewMeta,
DashboardMeta,
DashboardWidgetMeta,
+ DatasetMeta,
+ DatasetMeasure,
+ DatasetDimension,
ActionMeta,
ActionParamMeta,
FieldDefinition,
diff --git a/components/renderers/types.ts b/components/renderers/types.ts
index 8b79668..32174bb 100644
--- a/components/renderers/types.ts
+++ b/components/renderers/types.ts
@@ -182,7 +182,12 @@ export interface DashboardWidgetMeta {
name: string;
/** Spec dashboards key widgets by `id`; normalized into `name` on load. */
id?: string;
- object: string;
+ /**
+ * Data source object. Optional because 8.0 spec widgets declare a `dataset`
+ * (an analytics view) instead; `resolveDatasetWidget` resolves the dataset's
+ * base object into this field before the widget is queried.
+ */
+ object?: string;
type?: string;
title?: string;
description?: string;
@@ -195,6 +200,48 @@ export interface DashboardWidgetMeta {
options?: unknown;
/** Number of grid columns this widget spans (default 1) */
span?: number;
+ /* --- 8.0 spec dashboard widget shape (resolved via `dataset` metadata) --- */
+ /** Analytics dataset this widget aggregates over (8.0 spec). */
+ dataset?: string;
+ /** Measure names selected from the dataset (8.0 spec); first drives the value. */
+ values?: string[];
+ /** Dimension names to group by (8.0 spec); first drives the chart category. */
+ dimensions?: string[];
+ /** Grid placement (8.0 spec): `{ x, y, w, h }` in a 12-column grid. */
+ layout?: { x?: number; y?: number; w?: number; h?: number };
+}
+
+/** A measure (aggregation) declared by an analytics dataset (8.0 spec). */
+export interface DatasetMeasure {
+ name: string;
+ label?: string;
+ aggregate?: "count" | "sum" | "avg" | "min" | "max";
+ /** Source object field the aggregate runs over (absent for `count`). */
+ field?: string;
+ format?: string;
+}
+
+/** A dimension (groupable field) declared by an analytics dataset (8.0 spec). */
+export interface DatasetDimension {
+ name: string;
+ label?: string;
+ /** Source object field this dimension maps to. */
+ field?: string;
+ type?: string;
+}
+
+/**
+ * Analytics dataset metadata (8.0 spec, served at `/meta/dataset/`).
+ * A dataset is an analytics view over a base `object`, exposing groupable
+ * `dimensions` and aggregatable `measures`.
+ */
+export interface DatasetMeta {
+ name: string;
+ label?: string;
+ description?: string;
+ object: string;
+ dimensions?: DatasetDimension[];
+ measures?: DatasetMeasure[];
}
export interface DashboardMeta {
diff --git a/hooks/useDashboardData.ts b/hooks/useDashboardData.ts
index fd95b90..18f07f5 100644
--- a/hooks/useDashboardData.ts
+++ b/hooks/useDashboardData.ts
@@ -1,7 +1,10 @@
import { useMemo } from "react";
import { useQuery } from "@objectstack/client-react";
import { mongoFilterToAst } from "~/lib/query-builder";
-import type { DashboardWidgetMeta } from "~/components/renderers/types";
+import type {
+ DashboardWidgetMeta,
+ DatasetMeta,
+} from "~/components/renderers/types";
import type { WidgetDataPayload } from "~/components/renderers/DashboardViewRenderer";
/* ------------------------------------------------------------------ */
@@ -119,7 +122,7 @@ function buildChartData(
): Array<{ label: string; value: number }> {
if (!categoryField || records.length === 0) return [];
- const buckets = new Map();
+ const buckets = new Map();
let isDateCategory = false;
for (const rec of records) {
@@ -127,9 +130,10 @@ function buildChartData(
if (isDate) isDateCategory = true;
let bucket = buckets.get(key);
if (!bucket) {
- bucket = { label, nums: [] };
+ bucket = { label, nums: [], count: 0 };
buckets.set(key, bucket);
}
+ bucket.count += 1;
if (valueField) {
const n = Number(rec[valueField]);
if (!isNaN(n)) bucket.nums.push(n);
@@ -139,7 +143,9 @@ function buildChartData(
const series = Array.from(buckets.entries()).map(([key, b]) => ({
key,
label: b.label,
- value: applyAggregate(aggregate, b.nums),
+ // `count` aggregates over rows, not a value field — use the bucket size so
+ // count charts (the common dataset case, no `valueField`) aren't all zero.
+ value: aggregate === "count" ? b.count : applyAggregate(aggregate, b.nums),
}));
if (isDateCategory) {
@@ -152,6 +158,59 @@ function buildChartData(
return series.slice(0, MAX_CHART_BUCKETS).map(({ label, value }) => ({ label, value }));
}
+/* ------------------------------------------------------------------ */
+/* Dataset → object-query resolution (8.0 spec) */
+/* ------------------------------------------------------------------ */
+
+/** Map a grid column width (12-col system) to the renderer's 1–2 span. */
+function spanFromLayoutWidth(w: number | undefined): number {
+ return typeof w === "number" && w >= 8 ? 2 : 1;
+}
+
+/**
+ * Resolve an 8.0-spec dashboard widget — which references an analytics
+ * `dataset` plus `values` (measures) / `dimensions` — into the object-query
+ * shape (`object` / `aggregate` / `valueField` / `categoryField`) the widget
+ * data hook already understands. The dataset is an analytics view over a base
+ * object, so we aggregate the base object's rows client-side rather than
+ * depending on a server analytics endpoint (which the published runtime may
+ * not mount). Widgets without a `dataset` pass through unchanged.
+ */
+export function resolveDatasetWidget(
+ widget: DashboardWidgetMeta,
+ dataset: DatasetMeta | undefined,
+): DashboardWidgetMeta {
+ if (!widget.dataset || !dataset) return widget;
+
+ const measureName = widget.values?.[0];
+ const measure = measureName
+ ? dataset.measures?.find((m) => m.name === measureName)
+ : undefined;
+ const dimensionName = widget.dimensions?.[0];
+ const dimension = dimensionName
+ ? dataset.dimensions?.find((d) => d.name === dimensionName)
+ : undefined;
+
+ const options = (widget.options ?? {}) as Record;
+ const color = typeof options.color === "string" ? options.color : undefined;
+
+ return {
+ ...widget,
+ object: dataset.object,
+ aggregate: measure?.aggregate ?? "count",
+ // `count` measures have no source field — count rows instead.
+ valueField:
+ measure && measure.aggregate !== "count" ? measure.field : undefined,
+ categoryField: dimension?.field,
+ span: widget.span ?? spanFromLayoutWidth(widget.layout?.w),
+ chartConfig: {
+ ...options,
+ ...(color ? { colors: [color] } : null),
+ ...(measure?.format ? { format: measure.format } : null),
+ },
+ };
+}
+
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
@@ -162,6 +221,11 @@ function buildChartData(
* Each widget declares an `object` (the data source) and optional
* `aggregate`, `valueField`, and `categoryField` hints. This hook
* runs a live query that keeps the widget up-to-date.
+ *
+ * A widget with no resolvable `object` (e.g. a dataset whose metadata failed
+ * to load) returns a terminal empty payload rather than a perpetual loading
+ * state — `useQuery` leaves `isLoading: true` forever when disabled, which
+ * would otherwise spin the widget indefinitely.
*/
export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload {
const type = widget.type ?? "metric";
@@ -189,13 +253,19 @@ export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload {
[filterKey],
);
- const { data, isLoading } = useQuery(widget.object, {
+ const { data, isLoading } = useQuery(widget.object ?? "", {
top,
filters: astFilter,
enabled: !!widget.object,
});
return useMemo(() => {
+ // No data source to query — `useQuery` is disabled and its `isLoading`
+ // stays `true` forever, so short-circuit to a terminal empty state.
+ if (!widget.object) {
+ return { value: undefined, records: [], isLoading: false };
+ }
+
const records: Record[] = data?.records ?? [];
if (isLoading) {
diff --git a/lib/query-builder.ts b/lib/query-builder.ts
index c968663..ed3c762 100644
--- a/lib/query-builder.ts
+++ b/lib/query-builder.ts
@@ -299,6 +299,9 @@ export function resolveFilterMacro(value: unknown): unknown {
if ((m = /^(?:last_(\d+)_days|(\d+)_days_ago)$/.exec(token))) {
return now.getTime() - Number(m[1] ?? m[2]) * 86_400_000;
}
+ if ((m = /^(?:last_(\d+)_weeks?|(\d+)_weeks?_ago)$/.exec(token))) {
+ return now.getTime() - Number(m[1] ?? m[2]) * 7 * 86_400_000;
+ }
if ((m = /^(?:last_(\d+)_months|(\d+)_months_ago)$/.exec(token))) {
const months = Number(m[1] ?? m[2]);
return new Date(y, now.getMonth() - months, now.getDate()).getTime();
@@ -313,6 +316,11 @@ export function resolveFilterMacro(value: unknown): unknown {
if (token === "current_year_end" || token === "this_year_end") {
return new Date(y, 11, 31, 23, 59, 59, 999).getTime();
}
+ if (token === "current_week_start" || token === "this_week_start") {
+ // Week starts Monday (ISO): shift back to the most recent Monday.
+ const dow = (now.getDay() + 6) % 7; // 0 = Monday … 6 = Sunday
+ return startOfDay(now) - dow * 86_400_000;
+ }
if (token === "current_month_start" || token === "this_month_start") {
return new Date(y, now.getMonth(), 1).getTime();
}