New

Integration Settings

A list of third-party service cards with connection status, sync health, and an inline auth flow. Composes Card, Badge, and Form components. Reference: Warren's Slack, Teams, and Harvest settings views.

Demo

S
SlackConnected

Post status updates and notifications to channels.

Last synced 2 mins ago
H
HarvestConnected

Sync tracked time entries and billable hours.

Rate limit exceeded — retry in 10 min
T
TeamsDisconnected

Receive daily summaries in Microsoft Teams.

N
NotionDisconnected

Export completed tasks to a Notion database.

Card anatomy

ElementDescriptionToken
Service logo40×40 rounded rect, brand logo image or initial fallback--surface-raised
Name14 px semibold
Status badgeConnected = success green; Disconnected = neutral gray--status-success, --border
Description12 px, secondary colour--text-secondary
Sync rowTimestamp or error message with icon, 11 px--text-tertiary / --status-danger
Connected tintCard background gets --status-success / 0.04 fill--status-success

Auth flow

Tapping Connect opens either:

  • An inline expanded section with an API key input + “Test & Connect” button (for API-key integrations)
  • A bottom sheet or modal with OAuth instructions (for OAuth integrations)

The “Test & Connect” button shows a loading spinner during the API check and surfaces error messages inline. On success, the card transitions to the Connected state with an animation.

Sync status indicators

StateIconColour
Sync OKcheckmark.circle.fill--status-success
Sync errorexclamationmark.triangle.fill--status-danger
Syncing nowSpinner--text-tertiary

Multi-provider timesheet sync

Warren extends the basic integration-card pattern with a multi-provider timesheet sync engine. Each provider— Clockify, Toggl Track, and Timely—conforms to the same TimesheetProvider protocol and is orchestrated by a shared TimesheetSyncEngine singleton.

ProviderAuthAPI endpoint
ClockifyAPI key (Header X-Api-Key)api.clockify.me/v1
Toggl TrackAPI token (HTTP Basic)api.track.toggl.com/api/v9
TimelyOAuth 2.0 Bearer token + Account IDapi.timelyapp.com/1.1

Protocol

protocol TimesheetProvider: Sendable {
    var providerType: ProviderType { get }
    func testConnection() async throws
    func fetchProjects() async throws -> [LocalProject]
    func fetchUsers()    async throws -> [LocalUser]
    func fetchTimeEntries(since: Date) async throws -> [LocalTimeEntry]
}

Sync flow

  1. Delete window — existing provider-prefixed logs inside the lookback window are removed, clearing stale data before reinsertion.
  2. FetchfetchTimeEntries(since:) returns entries modified in the last 30 days.
  3. MapTimesheetMappingStore translates external project/user IDs to Warren IDs. Unmapped entries are skipped and surfaced to the user.
  4. Insert — matched entries are written as TimeLog objects with a provider prefix in the description (e.g. [clockify] ).

Settings UI

TimesheetSettingsView wraps all four providers (Harvest + the three new ones) under a segmented picker. Each pane shares the same three-section layout: Credentials → Member Mapping → Project Mapping → Sync. Credentials are stored in Keychain; project and member mappings are persisted in TimesheetMappingStore.

// Call from the background to sync all configured providers
await TimesheetSyncEngine.shared.syncAll(dataStore: dataStore, lookbackDays: 30)

Implementation

IntegrationSettings.tsx
tsx
import { useState } from "react";

interface Integration {
  id: string;
  name: string;
  description: string;
  status: "connected" | "disconnected";
  lastSync?: string | null;
  syncError?: string | null;
}

export function IntegrationCard({ integration }: { integration: Integration }) {
  const [showAuth, setShowAuth] = useState(false);
  const isConnected = integration.status === "connected";

  return (
    <div
      style={{
        padding: "16px 20px",
        borderRadius: 12,
        background: isConnected
          ? "rgb(var(--status-success) / 0.04)"
          : "rgb(var(--surface))",
        border: "1px solid rgb(var(--border))",
        boxShadow: "var(--shadow-card)",
        display: "flex",
        alignItems: "flex-start",
        gap: 14,
      }}
    >
      {/* Service logo placeholder */}
      <div
        style={{
          width: 40,
          height: 40,
          borderRadius: 10,
          background: "rgb(var(--surface-raised))",
          border: "1px solid rgb(var(--border))",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: 18,
          flexShrink: 0,
        }}
      >
        {integration.name[0]}
      </div>

      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
          <span style={{ fontSize: 14, fontWeight: 600 }}>{integration.name}</span>

          {/* Status badge */}
          <span style={{
            fontSize: 11, fontWeight: 600, padding: "2px 8px", borderRadius: 99,
            background: isConnected ? "rgb(var(--status-success) / 0.12)" : "rgb(var(--border))",
            color: isConnected ? "rgb(var(--status-success))" : "rgb(var(--text-tertiary))",
          }}>
            {isConnected ? "Connected" : "Disconnected"}
          </span>
        </div>

        <p style={{ fontSize: 12, color: "rgb(var(--text-secondary))", marginBottom: 8 }}>
          {integration.description}
        </p>

        {/* Sync status */}
        {isConnected && (
          <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "rgb(var(--text-tertiary))" }}>
            {integration.syncError ? (
              <>
                <AlertCircle size={12} color="rgb(var(--status-danger))" />
                <span style={{ color: "rgb(var(--status-danger))" }}>{integration.syncError}</span>
              </>
            ) : (
              <>
                <CheckCircle2 size={12} color="rgb(var(--status-success))" />
                <span>Last synced {integration.lastSync}</span>
              </>
            )}
          </div>
        )}

        {/* Auth flow */}
        {showAuth && !isConnected && (
          <div style={{
            marginTop: 12,
            padding: 14,
            borderRadius: 8,
            background: "rgb(var(--surface-raised))",
            border: "1px solid rgb(var(--border))",
            fontSize: 13,
          }}>
            <p style={{ marginBottom: 10, fontWeight: 500 }}>Connect {integration.name}</p>
            <input
              type="text"
              placeholder="Paste your API key or OAuth token"
              style={{
                width: "100%",
                padding: "8px 12px",
                borderRadius: 7,
                border: "1px solid rgb(var(--border))",
                background: "rgb(var(--surface))",
                fontSize: 13,
                boxSizing: "border-box",
              }}
            />
            <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
              <button style={{ padding: "7px 14px", borderRadius: 7, background: "rgb(var(--accent))", color: "#fff", border: "none", cursor: "pointer", fontSize: 13, fontWeight: 600 }}>
                Test & Connect
              </button>
              <button onClick={() => setShowAuth(false)} style={{ padding: "7px 14px", borderRadius: 7, background: "transparent", border: "1px solid rgb(var(--border))", cursor: "pointer", fontSize: 13 }}>
                Cancel
              </button>
            </div>
          </div>
        )}
      </div>

      {/* Action buttons */}
      <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
        {isConnected ? (
          <>
            <button title="Sync now" style={{ padding: 8, borderRadius: 7, border: "1px solid rgb(var(--border))", background: "rgb(var(--surface-raised))", cursor: "pointer" }}>
              <RotateCcw size={14} color="rgb(var(--text-secondary))" />
            </button>
            <button title="Configure" style={{ padding: 8, borderRadius: 7, border: "1px solid rgb(var(--border))", background: "rgb(var(--surface-raised))", cursor: "pointer" }}>
              <Settings size={14} color="rgb(var(--text-secondary))" />
            </button>
            <button title="Disconnect" style={{ padding: 8, borderRadius: 7, border: "1px solid rgb(var(--border))", background: "rgb(var(--surface-raised))", cursor: "pointer" }}>
              <Unplug size={14} color="rgb(var(--status-danger))" />
            </button>
          </>
        ) : (
          <button
            onClick={() => setShowAuth(true)}
            style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 14px", borderRadius: 7, background: "rgb(var(--accent))", color: "#fff", border: "none", cursor: "pointer", fontSize: 13, fontWeight: 600 }}
          >
            <Plug size={13} />
            Connect
          </button>
        )}
      </div>
    </div>
  );
}