diff --git a/src/hooks/use-presence.ts b/src/hooks/use-presence.ts
new file mode 100644
index 0000000..4d6c8b0
--- /dev/null
+++ b/src/hooks/use-presence.ts
@@ -0,0 +1,171 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { RealtimeChannel } from "@supabase/supabase-js";
+
+import { createClient } from "@/lib/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import {
+ derivePresence,
+ type PresenceRow,
+ type PresenceStatus,
+ type StoredPresence,
+} from "@/lib/presence";
+
+// How often the viewer re-derives presence locally. The online→offline
+// transition fires NO database event (it's just the clock passing the
+// staleness threshold), so without this tick a member who closes their
+// tab would appear online forever. ~15s keeps "offline" responsive
+// without busy-spinning.
+const RE_DERIVE_MS = 15_000;
+
+type PresenceMap = Map
;
+
+interface UsePresenceResult {
+ /** Derived status for one member (defaults to offline if unseen). */
+ getPresence: (userId: string) => PresenceStatus;
+ /** Raw row for tooltips ("last seen …"). */
+ getRow: (userId: string) => PresenceRow | undefined;
+ /**
+ * The clock value the hook is currently deriving against. Pass this
+ * to `presenceLabel` / `formatLastSeen` so labels stay in lockstep
+ * with the dots (both advance on the same ~15s re-derive tick).
+ */
+ now: number;
+}
+
+/**
+ * Live presence for every member of the caller's account. Reads the
+ * `member_presence` table (RLS-scoped to the account), subscribes to
+ * Realtime changes, and re-derives "offline" on a local timer.
+ *
+ * Account comes from useAuth; pass `enabled: false` to opt a consumer
+ * out (e.g. while a parent sheet is closed).
+ */
+export function usePresence(enabled = true): UsePresenceResult {
+ const { accountId } = useAuth();
+
+ // Presence rows keyed by user_id, held in immutable state — each
+ // update replaces the Map so React renders and the derived getters
+ // recompute. No ref/version dance needed.
+ const [rows, setRows] = useState(() => new Map());
+
+ // `now` ticks so derivePresence re-evaluates staleness over time.
+ const [now, setNow] = useState(() => Date.now());
+
+ const active = enabled && !!accountId;
+
+ useEffect(() => {
+ if (!active || !accountId) return;
+
+ const supabase = createClient();
+ let cancelled = false;
+
+ const applyRow = (row: {
+ user_id: string;
+ status: StoredPresence;
+ last_seen_at: string;
+ }) => {
+ setRows((prev) => {
+ const next = new Map(prev);
+ next.set(row.user_id, {
+ status: row.status,
+ last_seen_at: row.last_seen_at,
+ });
+ return next;
+ });
+ };
+
+ // Subscribe FIRST, then snapshot. The snapshot MERGES into whatever
+ // Realtime has already delivered (keeping the newer last_seen_at)
+ // rather than replacing the map — so an event that lands while the
+ // fetch is in flight isn't clobbered by a staler snapshot row.
+ const channel: RealtimeChannel = supabase
+ .channel(`presence:${accountId}`)
+ .on(
+ "postgres_changes",
+ {
+ event: "*",
+ schema: "public",
+ table: "member_presence",
+ filter: `account_id=eq.${accountId}`,
+ },
+ (payload) => {
+ if (payload.eventType === "DELETE") {
+ const old = payload.old as { user_id?: string };
+ if (!old.user_id) return;
+ setRows((prev) => {
+ if (!prev.has(old.user_id!)) return prev;
+ const next = new Map(prev);
+ next.delete(old.user_id!);
+ return next;
+ });
+ return;
+ }
+ applyRow(
+ payload.new as {
+ user_id: string;
+ status: StoredPresence;
+ last_seen_at: string;
+ },
+ );
+ },
+ )
+ .subscribe();
+
+ supabase
+ .from("member_presence")
+ .select("user_id, status, last_seen_at")
+ .eq("account_id", accountId)
+ .then(({ data, error }) => {
+ if (cancelled) return;
+ if (error) {
+ console.error("[usePresence] initial fetch error:", error.message);
+ return;
+ }
+ setRows((prev) => {
+ const next = new Map(prev);
+ for (const r of data ?? []) {
+ const userId = r.user_id as string;
+ const incoming: PresenceRow = {
+ status: r.status as StoredPresence,
+ last_seen_at: r.last_seen_at as string,
+ };
+ const existing = next.get(userId);
+ // A live event that arrived first must win over a staler
+ // snapshot row.
+ if (
+ !existing ||
+ new Date(incoming.last_seen_at) >= new Date(existing.last_seen_at)
+ ) {
+ next.set(userId, incoming);
+ }
+ }
+ return next;
+ });
+ });
+
+ const tick = setInterval(() => setNow(Date.now()), RE_DERIVE_MS);
+
+ return () => {
+ cancelled = true;
+ clearInterval(tick);
+ supabase.removeChannel(channel);
+ };
+ }, [active, accountId]);
+
+ const getRow = useCallback(
+ (userId: string): PresenceRow | undefined => rows.get(userId),
+ [rows],
+ );
+
+ const getPresence = useCallback(
+ (userId: string): PresenceStatus => {
+ const row = rows.get(userId);
+ return derivePresence(row?.status, row?.last_seen_at, now);
+ },
+ [rows, now],
+ );
+
+ return { getPresence, getRow, now };
+}
diff --git a/src/lib/presence.test.ts b/src/lib/presence.test.ts
new file mode 100644
index 0000000..2eb979d
--- /dev/null
+++ b/src/lib/presence.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ OFFLINE_AFTER_MS,
+ derivePresence,
+ formatLastSeen,
+ presenceLabel,
+ summarize,
+} from "./presence";
+
+// Fixed reference clock so every case is deterministic.
+const NOW = new Date("2026-06-22T12:00:00.000Z").getTime();
+const ago = (ms: number) => new Date(NOW - ms).toISOString();
+
+describe("derivePresence", () => {
+ it("returns the stored status for a fresh heartbeat", () => {
+ expect(derivePresence("online", ago(1_000), NOW)).toBe("online");
+ expect(derivePresence("away", ago(1_000), NOW)).toBe("away");
+ });
+
+ it("reads as offline when the heartbeat is stale", () => {
+ expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe(
+ "offline",
+ );
+ // Stored 'away' goes stale to offline too (tab was closed while idle).
+ expect(derivePresence("away", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe(
+ "offline",
+ );
+ });
+
+ it("treats a missing row or timestamp as offline", () => {
+ expect(derivePresence(undefined, null, NOW)).toBe("offline");
+ expect(derivePresence("online", null, NOW)).toBe("offline");
+ expect(derivePresence("online", "not-a-date", NOW)).toBe("offline");
+ });
+
+ it("stays online exactly at the threshold and flips just past it", () => {
+ expect(derivePresence("online", ago(OFFLINE_AFTER_MS), NOW)).toBe("online");
+ expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1), NOW)).toBe(
+ "offline",
+ );
+ });
+});
+
+describe("formatLastSeen", () => {
+ it("describes recent activity coarsely", () => {
+ expect(formatLastSeen(ago(10_000), NOW)).toBe("just now");
+ expect(formatLastSeen(ago(60_000), NOW)).toBe("1 minute ago");
+ expect(formatLastSeen(ago(5 * 60_000), NOW)).toBe("5 minutes ago");
+ });
+
+ it("rolls up into hours and days", () => {
+ expect(formatLastSeen(ago(60 * 60_000), NOW)).toBe("1 hour ago");
+ expect(formatLastSeen(ago(2 * 60 * 60_000), NOW)).toBe("2 hours ago");
+ expect(formatLastSeen(ago(24 * 60 * 60_000), NOW)).toBe("1 day ago");
+ expect(formatLastSeen(ago(3 * 24 * 60 * 60_000), NOW)).toBe("3 days ago");
+ });
+
+ it("falls back gracefully on missing/invalid input", () => {
+ expect(formatLastSeen(null, NOW)).toBe("a while ago");
+ expect(formatLastSeen("nonsense", NOW)).toBe("a while ago");
+ });
+});
+
+describe("presenceLabel", () => {
+ it("labels each state for the tooltip", () => {
+ expect(presenceLabel("online", ago(1_000), NOW)).toBe(
+ "Online — active now",
+ );
+ expect(presenceLabel("away", ago(1_000), NOW)).toBe("Away — idle");
+ expect(presenceLabel("offline", ago(2 * 60 * 60_000), NOW)).toBe(
+ "Offline — last seen 2 hours ago",
+ );
+ });
+});
+
+describe("summarize", () => {
+ it("counts each status", () => {
+ expect(
+ summarize(["online", "online", "online", "away", "offline"]),
+ ).toEqual({ online: 3, away: 1, offline: 1 });
+ });
+
+ it("returns zeroes for an empty roster", () => {
+ expect(summarize([])).toEqual({ online: 0, away: 0, offline: 0 });
+ });
+});
diff --git a/src/lib/presence.ts b/src/lib/presence.ts
new file mode 100644
index 0000000..925cf85
--- /dev/null
+++ b/src/lib/presence.ts
@@ -0,0 +1,121 @@
+// ============================================================
+// Presence helpers — pure, unit-testable, no I/O.
+//
+// Mirrors the `member_presence` table from migration
+// 024_member_presence.sql. The DB stores only what the active
+// client reports ('online' / 'away'); "offline" is never stored
+// — it is derived here from staleness so a closed tab resolves to
+// offline without an unload write.
+//
+// `now` is always passed in (epoch ms) rather than read from the
+// clock, so derivation and formatting stay deterministic and
+// testable. See presence.test.ts.
+// ============================================================
+
+/** How often the active client heartbeats its own presence row. */
+export const HEARTBEAT_MS = 30_000;
+
+/**
+ * A member whose last heartbeat is older than this is treated as
+ * offline regardless of its stored status. ~2.5 missed beats, so a
+ * single dropped heartbeat doesn't flap a member offline.
+ */
+export const OFFLINE_AFTER_MS = 75_000;
+
+/** No input / hidden tab for this long flips the client to 'away'. */
+export const IDLE_AFTER_MS = 5 * 60_000;
+
+/** What the active client reports (and what the DB stores). */
+export type StoredPresence = "online" | "away";
+
+/** What a viewer sees — adds the derived 'offline' state. */
+export type PresenceStatus = "online" | "away" | "offline";
+
+/** Raw presence row as read from the `member_presence` table. */
+export interface PresenceRow {
+ status: StoredPresence;
+ last_seen_at: string;
+}
+
+/**
+ * Derive the user-facing presence for a member. A missing row, or a
+ * heartbeat staler than OFFLINE_AFTER_MS, reads as offline; otherwise
+ * the member's last reported status (online / away) stands.
+ */
+export function derivePresence(
+ stored: StoredPresence | undefined,
+ lastSeenAt: string | null | undefined,
+ now: number,
+): PresenceStatus {
+ if (!stored || !lastSeenAt) return "offline";
+ const last = new Date(lastSeenAt).getTime();
+ if (Number.isNaN(last)) return "offline";
+ if (now - last > OFFLINE_AFTER_MS) return "offline";
+ return stored;
+}
+
+/**
+ * Relative "last seen" string for tooltips. Coarse on purpose — the
+ * issue calls for relative time only, never a precise timestamp.
+ *
+ * Deliberately separate from `formatRelative` in
+ * src/lib/automations/trigger-meta.ts: that one reads `Date.now()`
+ * internally (not injectable) and emits terse chip wording ("2h ago"),
+ * whereas presence needs an injected `now` — so the dots and labels
+ * advance in lockstep and the unit tests stay deterministic — plus
+ * full-sentence wording for the tooltip ("Offline — last seen …").
+ */
+export function formatLastSeen(
+ lastSeenAt: string | null | undefined,
+ now: number,
+): string {
+ if (!lastSeenAt) return "a while ago";
+ const last = new Date(lastSeenAt).getTime();
+ if (Number.isNaN(last)) return "a while ago";
+
+ const diff = Math.max(0, now - last);
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 1) return "just now";
+ if (mins === 1) return "1 minute ago";
+ if (mins < 60) return `${mins} minutes ago`;
+
+ const hours = Math.floor(mins / 60);
+ if (hours === 1) return "1 hour ago";
+ if (hours < 24) return `${hours} hours ago`;
+
+ const days = Math.floor(hours / 24);
+ if (days === 1) return "1 day ago";
+ return `${days} days ago`;
+}
+
+/**
+ * Tooltip / aria label for a presence dot, e.g.
+ * "Online — active now"
+ * "Away — idle"
+ * "Offline — last seen 2 hours ago"
+ */
+export function presenceLabel(
+ status: PresenceStatus,
+ lastSeenAt: string | null | undefined,
+ now: number,
+): string {
+ switch (status) {
+ case "online":
+ return "Online — active now";
+ case "away":
+ return "Away — idle";
+ case "offline":
+ return `Offline — last seen ${formatLastSeen(lastSeenAt, now)}`;
+ }
+}
+
+/** Roster header summary, e.g. for "3 online · 1 away · 1 offline". */
+export function summarize(statuses: PresenceStatus[]): {
+ online: number;
+ away: number;
+ offline: number;
+} {
+ const counts = { online: 0, away: 0, offline: 0 };
+ for (const s of statuses) counts[s] += 1;
+ return counts;
+}
diff --git a/supabase/migrations/024_member_presence.sql b/supabase/migrations/024_member_presence.sql
new file mode 100644
index 0000000..78fa61f
--- /dev/null
+++ b/supabase/migrations/024_member_presence.sql
@@ -0,0 +1,101 @@
+-- ============================================================
+-- 024_member_presence.sql — team member presence (online / away)
+--
+-- Adds a lightweight presence layer so the Team members roster (and
+-- the inbox Assign dropdown) can show who is actively using the
+-- dashboard, idle, or gone. Implements wacrm#269.
+--
+-- Design
+--
+-- The active client heartbeats its own row through the
+-- `touch_presence` RPC roughly every 30s, storing only 'online'
+-- or 'away'. "Offline" is NOT stored — viewers derive it from
+-- staleness (`now() - last_seen_at` beyond a threshold), so a
+-- closed tab / logout resolves to offline automatically without
+-- relying on an unreliable unload write.
+--
+-- A dedicated table keeps the high-write heartbeat off the
+-- otherwise-stable `profiles` row and scopes Realtime cleanly.
+--
+-- Visibility
+--
+-- Any account member can read presence for their account — the
+-- same visibility as the read-only roster (`is_account_member`).
+-- Writes go ONLY through the SECURITY DEFINER RPC, which derives
+-- the account from the caller's profile (never client-supplied).
+--
+-- Idempotent — safe to run multiple times.
+-- ============================================================
+
+-- ---- table -------------------------------------------------
+CREATE TABLE IF NOT EXISTS member_presence (
+ user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'away')),
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS member_presence_account_idx
+ ON member_presence(account_id);
+
+-- ---- RLS ---------------------------------------------------
+ALTER TABLE member_presence ENABLE ROW LEVEL SECURITY;
+
+-- Account members may read every presence row for their account.
+-- No client INSERT/UPDATE/DELETE policy exists: all writes flow
+-- through touch_presence() below.
+DROP POLICY IF EXISTS member_presence_select ON member_presence;
+CREATE POLICY member_presence_select ON member_presence FOR SELECT
+ USING (is_account_member(account_id));
+
+-- ---- heartbeat RPC -----------------------------------------
+-- Upserts the caller's presence row. SECURITY DEFINER so it can
+-- write despite the absence of a client write policy; the account
+-- is resolved from the caller's own profile, so a client can never
+-- spoof which account it appears in.
+CREATE OR REPLACE FUNCTION public.touch_presence(
+ p_status TEXT DEFAULT 'online'
+) RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_account_id UUID;
+BEGIN
+ IF auth.uid() IS NULL THEN
+ RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
+ END IF;
+
+ IF p_status NOT IN ('online', 'away') THEN
+ RAISE EXCEPTION 'Invalid presence status: %', p_status
+ USING ERRCODE = '22023';
+ END IF;
+
+ SELECT account_id INTO v_account_id
+ FROM profiles
+ WHERE user_id = auth.uid();
+
+ IF v_account_id IS NULL THEN
+ RAISE EXCEPTION 'No account for caller' USING ERRCODE = '22023';
+ END IF;
+
+ INSERT INTO member_presence (user_id, account_id, status, last_seen_at)
+ VALUES (auth.uid(), v_account_id, p_status, now())
+ ON CONFLICT (user_id) DO UPDATE
+ SET status = excluded.status,
+ last_seen_at = now(),
+ account_id = excluded.account_id;
+END;
+$$;
+
+-- ---- realtime ----------------------------------------------
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime' AND tablename = 'member_presence'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE member_presence;
+ END IF;
+END $$;