diff --git a/src/App.tsx b/src/App.tsx
index df1dcd9..d1f0cfb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,7 @@ import AppLayout from "./components/layout/AppLayout";
import ProjectForm from "./components/projects/ProjectForm";
import ProjectDashboard from "./components/projects/ProjectDashboard";
import SettingsPage from "./components/settings/SettingsPage";
+import TrackerConfig from "./components/trackers/TrackerConfig";
function EmptyState() {
return (
@@ -21,6 +22,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/components/trackers/FilterBuilder.tsx b/src/components/trackers/FilterBuilder.tsx
new file mode 100644
index 0000000..45ad64f
--- /dev/null
+++ b/src/components/trackers/FilterBuilder.tsx
@@ -0,0 +1,182 @@
+import type { FilterGroup, Filter, TrackerField } from "../../lib/types";
+
+const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const;
+
+interface Props {
+ groups: FilterGroup[];
+ onChange: (groups: FilterGroup[]) => void;
+ availableFields: TrackerField[];
+}
+
+export default function FilterBuilder({ groups, onChange, availableFields }: Props) {
+ function updateGroup(groupIndex: number, group: FilterGroup) {
+ const next = groups.map((g, i) => (i === groupIndex ? group : g));
+ onChange(next);
+ }
+
+ function removeGroup(groupIndex: number) {
+ onChange(groups.filter((_, i) => i !== groupIndex));
+ }
+
+ function addGroup() {
+ onChange([...groups, { conditions: [{ field: "", operator: "In", value: [] }] }]);
+ }
+
+ function updateCondition(groupIndex: number, condIndex: number, cond: Filter) {
+ const group = groups[groupIndex];
+ const conditions = group.conditions.map((c, i) => (i === condIndex ? cond : c));
+ updateGroup(groupIndex, { conditions });
+ }
+
+ function removeCondition(groupIndex: number, condIndex: number) {
+ const group = groups[groupIndex];
+ const conditions = group.conditions.filter((_, i) => i !== condIndex);
+ updateGroup(groupIndex, { conditions });
+ }
+
+ function addCondition(groupIndex: number) {
+ const group = groups[groupIndex];
+ updateGroup(groupIndex, {
+ conditions: [...group.conditions, { field: "", operator: "In", value: [] }],
+ });
+ }
+
+ function toggleValue(groupIndex: number, condIndex: number, val: string) {
+ const cond = groups[groupIndex].conditions[condIndex];
+ const next = cond.value.includes(val)
+ ? cond.value.filter((v) => v !== val)
+ : [...cond.value, val];
+ updateCondition(groupIndex, condIndex, { ...cond, value: next });
+ }
+
+ return (
+
+ {groups.map((group, gi) => (
+
+ {gi > 0 && (
+
+ )}
+
+
+ Group {gi + 1}
+
+
+
+
+ {group.conditions.map((cond, ci) => {
+ const fieldDef = availableFields.find((f) => f.label === cond.field);
+ return (
+
+ {ci > 0 && (
+
+ )}
+
+
+ {/* Field dropdown */}
+
+
+ {/* Operator dropdown */}
+
+
+ {/* Remove condition */}
+
+
+
+ {/* Value pills */}
+ {fieldDef && fieldDef.values.length > 0 && (
+
+ {fieldDef.values.map((v) => {
+ const selected = cond.value.includes(String(v.id));
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/trackers/TrackerConfig.tsx b/src/components/trackers/TrackerConfig.tsx
new file mode 100644
index 0000000..8b05c15
--- /dev/null
+++ b/src/components/trackers/TrackerConfig.tsx
@@ -0,0 +1,190 @@
+import { useState } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { addTracker, getTrackerFields } from "../../lib/api";
+import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types";
+import FilterBuilder from "./FilterBuilder";
+
+export default function TrackerConfig() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const navigate = useNavigate();
+
+ const [trackerId, setTrackerId] = useState("");
+ const [trackerLabel, setTrackerLabel] = useState("");
+ const [pollingInterval, setPollingInterval] = useState(10);
+ const [fields, setFields] = useState([]);
+ const [fieldsLoaded, setFieldsLoaded] = useState(false);
+ const [fieldsLoading, setFieldsLoading] = useState(false);
+ const [filters, setFilters] = useState([]);
+ const [analystCommand, setAnalystCommand] = useState("claude");
+ const [developerCommand, setDeveloperCommand] = useState("claude");
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleLoadFields() {
+ if (!trackerId) return;
+ setFieldsLoading(true);
+ setError(null);
+ try {
+ const result = await getTrackerFields(Number(trackerId));
+ setFields(result);
+ setFieldsLoaded(true);
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setFieldsLoading(false);
+ }
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!projectId || trackerId === "") return;
+ setError(null);
+ setLoading(true);
+
+ const agentConfig: AgentConfig = {
+ analyst_command: analystCommand,
+ analyst_args: [],
+ developer_command: developerCommand,
+ developer_args: [],
+ };
+
+ try {
+ await addTracker(projectId, Number(trackerId), trackerLabel, pollingInterval, agentConfig, filters);
+ navigate(`/projects/${projectId}`);
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/trackers/TrackerList.tsx b/src/components/trackers/TrackerList.tsx
new file mode 100644
index 0000000..fd03506
--- /dev/null
+++ b/src/components/trackers/TrackerList.tsx
@@ -0,0 +1,112 @@
+import { Link } from "react-router-dom";
+import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
+import type { WatchedTracker } from "../../lib/types";
+
+interface Props {
+ trackers: WatchedTracker[];
+ projectId: string;
+ onRefresh: () => void;
+}
+
+export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
+ async function handlePollNow(tracker: WatchedTracker) {
+ try {
+ await manualPoll(tracker.id);
+ onRefresh();
+ } catch (err) {
+ console.error("Poll failed:", err);
+ }
+ }
+
+ async function handleToggleEnabled(tracker: WatchedTracker) {
+ try {
+ await updateTracker(
+ tracker.id,
+ tracker.polling_interval,
+ tracker.agent_config,
+ tracker.filters,
+ !tracker.enabled
+ );
+ onRefresh();
+ } catch (err) {
+ console.error("Update failed:", err);
+ }
+ }
+
+ async function handleRemove(tracker: WatchedTracker) {
+ if (!window.confirm(`Remove tracker "${tracker.tracker_label}"?`)) return;
+ try {
+ await removeTracker(tracker.id);
+ onRefresh();
+ } catch (err) {
+ console.error("Remove failed:", err);
+ }
+ }
+
+ return (
+
+ {trackers.length === 0 && (
+
No trackers configured.
+ )}
+
+ {trackers.map((tracker) => (
+
+
+
+ {tracker.tracker_label}
+ #{tracker.tracker_id}
+
+ {tracker.enabled ? "Active" : "Paused"}
+
+
+
+ {tracker.last_polled_at
+ ? `Last poll: ${new Date(tracker.last_polled_at).toLocaleString()}`
+ : "Never polled"}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ Add tracker
+
+
+ );
+}