New

Mobile Time Logging

Patterns for time-tracking mobile apps: a branded dark header with embedded week stats, a live active-timer card, grouped log lists with project color rails, and a sync-status system.

Preview

Warren

Time Tracker

86%
This week · May 18–24
34.5
/ 40.0 hrs
+2.3 wk
M
T
W
T
F
S
S
Project
Northwind Health
Task type
Design
High-fidelity mobile wireframes & Sitka integration
00:42:18
Add manual entry
Resume…

Today · Fri May 22

3 entries
4h 25m
Cascade Coffee
· DESIGN
Packaging system explorations — round 2 (kraft + foil)
9:1211:22Billable
2h 10m
Synced
Northwind Health
· DISCOVERY
Stakeholder synthesis & opportunity map
11:351:20Billable
1h 45m
Synced
Foxglove Studio
· REVIEW
Async loom feedback — onboarding v3
2:052:35Internal
0h 30m
Syncing

Yesterday · Thu May 21

3 entries
8h 00m
Meridian Labs
· ENGINEERING
Design tokens → Tailwind config wiring
8:4512:00Billable
3h 15m
Synced
Northwind Health
· DESIGN
Component audit: navigation, sheets, list cells
1:003:30Billable
2h 30m
Synced
Cascade Coffee
· DESIGN
Logo lockup refinement — vertical & horizontal
3:455:15Billable
1h 30m
Sync failed
SyncedSynced with Harvest · 2 min ago

Tap the Pause / Resume button to toggle the live timer. The pulsing dot and gradient animate in response.

Branded App Header

The header extends behind the status bar in the brand's primary dark color. A subtle dot-grid grain at 4% opacity adds texture without competing with content. The bottom of the header overlaps the first card by 14px — this overlap grounds the card visually and signals that the header and card are one compositional unit.

LayerValuePurpose
BackgroundBrand dark (#16302A)Strong brand presence; reverse-coloured text on top
Grain textureradial-gradient dot at 3px grid, 4% opacityAdds tactile depth without visible pattern at small sizes
Large title34px / 700 / −0.022em trackingMaps to iOS .largeTitle; feels native and spacious
Week summary cardrgba(white, 0.06) fill + rgba(white, 0.10) stroke, 14px radiusGlassmorphic surface on the dark header — stays legible in both themes
Card overlapmargin-top: −14px on the timer cardVisually binds the header and first card into one unit

Week Summary Card

Embedded within the header, the week summary card combines three data-density layers: a circular ring showing percentage completion, a numeric readout with a pro-rata delta, and a 7-bar mini chart. The ring animates from 0 on first mount using an easeOut(0.6s) stroke-dasharray transition.

ElementSpec
WeekRing64×64pt SVG, 6pt stroke, accent color, round linecap, rotated −90° so 0% starts at 12 o'clock
Percentage label18px bold + 10px % superscript, monospaced digits, white on dark
WeekBars8px wide × up to 32px tall bars, 6px gap, today's bar in accent color, others at rgba(white, 0.32)
DeltaPro-rata: logged − (goal × days_elapsed/7). +/− prefix, accent color text
Eyebrow10.5px / 600 / 0.14em tracking, UPPERCASE, 55% white opacity

Active Timer Card

The timer card has three rows separated by 1px dividers. The top row is a 2-column grid of tappable selectors (project and task type). The middle row holds a free-form note. The bottom row shows the live readout and the primary Pause / Resume action.

RowContentsInteraction
SelectorsProject color swatch (8px) + name + chevron | Task type + chevronFull-cell tap area; each half opens a picker/menu
NoteSingle-line text; placeholder when emptyTapping opens an edit sheet or inline text field
Timer rowPulsing dot + monospaced HH:MM:SS + Pause/Resume buttonButton toggles running state; dot and gradient respond immediately

Pulsing dot spec

The 8px dot has two layers: a static filled circle (accent when running, tertiary-text when paused) and an expanding ring that fades to transparent over 1.6s, repeating forever. This is a keyframe animation, not a spring — the cadence is slow and calm, not reactive.

  • Ring starts at scale(1), opacity 0.55
  • Ring expands to scale(2.4), opacity 0 over 1.6s easeOut
  • Only animate when running === true — stop immediately on pause

Manual Log Strip

A two-button strip placed immediately below the timer card. The left button uses a dashed border to signal an additive / creative action (creating something new). The right button uses a solid border for a secondary action (resuming an existing entry). The visual language of dashed vs. solid comes from conventions in drawing and CAD tools where dashed lines indicate potential or pending states.

ButtonBorderLabelFlex
Add manual entry1.5px dashed, --border-strongPlus icon + label, full widthflex: 1 — expands to fill remaining space
Resume…1px solid, --border-strongCheckmark icon + label, fixed widthflex: 0 — sized to content

Log Section & Row

Entries are grouped by day into sections. Each section has a header row (date label + entry count + total duration) and a rounded card containing the rows, separated by 1px lines. Within each row, a 4px color rail on the left edge conveys project identity — faster to scan than a project name alone.

PartDescription
Section headerUPPERCASE date label + entry count (subdued) + right-aligned total (monospaced, brand ink)
Card container14px radius, 1px border, white background — groups rows into a cohesive visual unit
Color rail4px wide × full row height, 2px radius, project swatch color — instant project identification
Project + type14px semibold project name + 10px UPPERCASE task type with · separator, text-tertiary
Note13px regular, text-secondary, single line with ellipsis truncation
Meta row11px time range (monospaced) + 3px dot separator + billable/internal status
Duration16px semibold, right-aligned, monospaced digits
Sync badge13px icon: checkmark.circle (moss green) · sync arrow (tertiary) · exclamation circle (rose)

Sync Status Badge

A three-state 13px icon indicator used on each log row and in the screen footer. States must be visually distinct without relying on color alone (use shape + color for accessibility).

StateIconColorMeaning
syncedCircle with checkmark#5C8C5A (moss)Entry has been pushed to the remote timesheet
pendingCircular arrow (refresh)--text-tertiarySync in progress or queued
failedCircle with exclamation point#C9533F (rose)Sync failed — user action may be required

Project Color Swatches

Projects are assigned colors from a curated palette of intentionally muted, mid-tone swatches. They are distinguishable under both light and dark themes without being garish. Colors cycle by project index.

Spruce
#16302A · Brand / index 0
Umber
#8A5A3B · Warm brown / index 1
Slate
#2D5F87 · Cool blue / index 2
Heather
#7A4B86 · Muted purple / index 3

Implementation

All four components — ActiveTimerCard, ManualLogStrip, LogSection, and LogRow — are self-contained. They use Sitka design tokens for all colors, typography, and radii. The Swift implementations require MobileDesignSystem.swift from the Warren iOS library.

TimeLoggingScreen.tsx
tsx
"use client";
import { useState, useEffect } from "react";

// Project palette — cycled by index
const PROJECT_COLORS = ["#16302A", "#8A5A3B", "#2D5F87", "#7A4B86"];

type SyncStatus = "synced" | "pending" | "failed";

interface TimeEntry {
  id: string;
  projectName: string;
  projectColor: string;
  taskType: string;
  note: string;
  durationLabel: string;
  timeRange: string;
  billable: boolean;
  sync: SyncStatus;
}

interface LogSectionProps {
  title: string;
  total: string;
  entries: TimeEntry[];
}

// ── Pulsing live-indicator dot ──────────────────────────────
function PulsingDot({ active }: { active: boolean }) {
  return (
    <div style={{ position: "relative", width: 8, height: 8, flexShrink: 0 }}>
      <div style={{
        position: "absolute", inset: 0, borderRadius: 4,
        background: active ? "rgb(var(--accent))" : "rgb(var(--text-tertiary))",
        ...(active && { animation: "timer-pulse 1.6s ease-out infinite" }),
      }} />
      <style>{`
        @keyframes timer-pulse {
          0%   { box-shadow: 0 0 0 0 rgba(var(--accent) / 0.55); }
          70%  { box-shadow: 0 0 0 8px rgba(var(--accent) / 0); }
          100% { box-shadow: 0 0 0 0 rgba(var(--accent) / 0); }
        }
      `}</style>
    </div>
  );
}

// ── Active timer card ───────────────────────────────────────
export function ActiveTimerCard({
  projectName,
  projectColor,
  taskType,
  note,
  running,
  elapsedSeconds,
  onToggle,
}: {
  projectName: string;
  projectColor: string;
  taskType: string;
  note: string;
  running: boolean;
  elapsedSeconds: number;
  onToggle: () => void;
}) {
  const hh = String(Math.floor(elapsedSeconds / 3600)).padStart(2, "0");
  const mm = String(Math.floor((elapsedSeconds % 3600) / 60)).padStart(2, "0");
  const ss = String(elapsedSeconds % 60).padStart(2, "0");

  return (
    <div style={{
      background: "rgb(var(--surface))",
      borderRadius: 16,
      border: "1px solid rgb(var(--border))",
      boxShadow: "0 1px 0 rgba(0,0,0,0.04)",
      overflow: "hidden",
    }}>
      {/* Row 1 — Project + Task type selectors */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", borderBottom: "1px solid rgb(var(--border))" }}>
        <button style={{
          all: "unset", cursor: "pointer",
          padding: "12px 14px", display: "flex", alignItems: "center", gap: 9,
          borderRight: "1px solid rgb(var(--border))",
        }}>
          <span style={{ width: 8, height: 8, borderRadius: 2, background: projectColor, flexShrink: 0 }} />
          <div style={{ minWidth: 0, flex: 1 }}>
            <div style={{ fontSize: 9.5, fontWeight: 600, color: "rgb(var(--text-tertiary))", letterSpacing: "0.12em", textTransform: "uppercase" }}>
              Project
            </div>
            <div style={{ fontSize: 14, fontWeight: 600, color: "rgb(var(--text-primary))", marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {projectName}
            </div>
          </div>
          <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
            <path d="M2.5 4l2.5 2.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>
        <button style={{
          all: "unset", cursor: "pointer",
          padding: "12px 14px", display: "flex", alignItems: "center", gap: 9,
        }}>
          <div style={{ minWidth: 0, flex: 1 }}>
            <div style={{ fontSize: 9.5, fontWeight: 600, color: "rgb(var(--text-tertiary))", letterSpacing: "0.12em", textTransform: "uppercase" }}>
              Task type
            </div>
            <div style={{ fontSize: 14, fontWeight: 600, color: "rgb(var(--text-primary))", marginTop: 1 }}>
              {taskType}
            </div>
          </div>
          <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
            <path d="M2.5 4l2.5 2.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>
      </div>

      {/* Row 2 — Note */}
      <div style={{
        padding: "10px 14px",
        borderBottom: "1px solid rgb(var(--border))",
        fontSize: 13.5, color: note ? "rgb(var(--text-secondary))" : "rgb(var(--text-tertiary))",
        lineHeight: "18px",
      }}>
        {note || "Add a note…"}
      </div>

      {/* Row 3 — Live timer + action */}
      <div style={{
        padding: "12px 14px",
        display: "flex", alignItems: "center", gap: 12,
        background: running ? "linear-gradient(180deg, rgb(var(--surface)) 0%, rgb(var(--surface-raised)) 100%)" : "rgb(var(--surface))",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0 }}>
          <PulsingDot active={running} />
          <div style={{
            fontSize: 28, fontWeight: 600,
            color: "rgb(var(--text-primary))",
            letterSpacing: "-0.01em", lineHeight: 1,
            fontVariantNumeric: "tabular-nums",
            fontFamily: "ui-monospace, monospace",
          }}>
            {hh}<span style={{ color: "rgb(var(--text-tertiary))" }}>:</span>
            {mm}<span style={{ color: "rgb(var(--text-tertiary))" }}>:</span>
            {ss}
          </div>
        </div>
        <button
          onClick={onToggle}
          style={{
            all: "unset", cursor: "pointer",
            background: "rgb(var(--accent))",
            color: "rgb(var(--accent-foreground))",
            height: 42, padding: "0 18px 0 14px",
            borderRadius: 10,
            display: "flex", alignItems: "center", gap: 8,
            fontSize: 14, fontWeight: 600,
          }}
        >
          {running ? "Pause" : "Resume"}
        </button>
      </div>
    </div>
  );
}

// ── Log section + rows ──────────────────────────────────────
function SyncIndicator({ status }: { status: SyncStatus }) {
  const colors: Record<SyncStatus, string> = {
    synced:  "#5C8C5A",
    pending: "rgb(var(--text-tertiary))",
    failed:  "#C9533F",
  };
  return (
    <div
      style={{ width: 13, height: 13, borderRadius: "50%", background: colors[status], opacity: 0.8 }}
      title={status}
    />
  );
}

function LogRow({ entry, isLast }: { entry: TimeEntry; isLast: boolean }) {
  return (
    <div style={{
      padding: "12px 16px",
      display: "flex", alignItems: "flex-start", gap: 12,
      borderBottom: isLast ? "none" : "1px solid rgb(var(--border-subtle))",
    }}>
      {/* Project color rail */}
      <div style={{
        width: 4, alignSelf: "stretch",
        borderRadius: 2, background: entry.projectColor,
        marginTop: 2, marginBottom: 2, flexShrink: 0,
      }} />

      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 2 }}>
          <span style={{
            fontSize: 14, fontWeight: 600,
            color: "rgb(var(--text-primary))", letterSpacing: "-0.005em",
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>
            {entry.projectName}
          </span>
          <span style={{
            fontSize: 10, fontWeight: 600,
            color: "rgb(var(--text-tertiary))", letterSpacing: "0.08em",
            textTransform: "uppercase", whiteSpace: "nowrap", flexShrink: 0,
          }}>
            · {entry.taskType}
          </span>
        </div>
        <div style={{ fontSize: 13, color: "rgb(var(--text-secondary))", lineHeight: "18px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          {entry.note}
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
          <span style={{ fontSize: 11, color: "rgb(var(--text-tertiary))", fontVariantNumeric: "tabular-nums" }}>
            {entry.timeRange}
          </span>
          <span style={{ width: 3, height: 3, borderRadius: 1.5, background: "rgb(var(--border))" }} />
          <span style={{ fontSize: 11, color: "rgb(var(--text-tertiary))" }}>
            {entry.billable ? "Billable" : "Internal"}
          </span>
        </div>
      </div>

      <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 }}>
        <span style={{ fontSize: 16, fontWeight: 600, color: "rgb(var(--text-primary))", fontVariantNumeric: "tabular-nums" }}>
          {entry.durationLabel}
        </span>
        <SyncIndicator status={entry.sync} />
      </div>
    </div>
  );
}

export function LogSection({ title, total, entries }: LogSectionProps) {
  return (
    <div>
      <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", padding: "18px 16px 8px" }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
          <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600, color: "rgb(var(--text-primary))", letterSpacing: "0.04em", textTransform: "uppercase" }}>
            {title}
          </h3>
          <span style={{ fontSize: 11, color: "rgb(var(--text-tertiary))" }}>{entries.length} entries</span>
        </div>
        <span style={{ fontSize: 13, fontWeight: 600, color: "rgb(var(--text-primary))", fontVariantNumeric: "tabular-nums" }}>
          {total}
        </span>
      </div>
      <div style={{
        background: "rgb(var(--surface))",
        borderRadius: 14, margin: "0 16px",
        border: "1px solid rgb(var(--border))",
        overflow: "hidden",
      }}>
        {entries.map((e, i) => <LogRow key={e.id} entry={e} isLast={i === entries.length - 1} />)}
      </div>
    </div>
  );
}

// ── Manual log strip ────────────────────────────────────────
export function ManualLogStrip({ onAdd, onResume }: { onAdd: () => void; onResume: () => void }) {
  const dashed: React.CSSProperties = {
    all: "unset" as "unset",
    cursor: "pointer",
    flex: 1, padding: "9px 12px",
    display: "flex", alignItems: "center", gap: 8,
    fontSize: 13, fontWeight: 500,
    color: "rgb(var(--text-secondary))",
    border: "1.5px dashed rgb(var(--border-strong))",
    borderRadius: 10,
  };
  const solid: React.CSSProperties = {
    all: "unset" as "unset",
    cursor: "pointer",
    padding: "9px 12px",
    display: "flex", alignItems: "center", gap: 6,
    fontSize: 13, fontWeight: 500,
    color: "rgb(var(--text-secondary))",
    border: "1px solid rgb(var(--border-strong))",
    borderRadius: 10,
  };
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px 0" }}>
      <button style={dashed} onClick={onAdd}>
        <svg width="14" height="14" viewBox="0 0 14 14"><path d="M7 2v10M2 7h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
        Add manual entry
      </button>
      <button style={solid} onClick={onResume}>
        Resume…
      </button>
    </div>
  );
}

Local-network pairing

Warren iOS pairs with Warren for Mac over the local network using a one-time token. The flow has two entry points — QR scan and paste code — so it works without a camera. No internet connection is required; sync happens directly device-to-device over Bonjour.

ComponentDescription
PairingViewFull-screen onboarding view. Centred icon + instructions, primary QR button, divider, monospaced paste-code field, disabled-until-valid Pair button.
QRScannerSheetVisionKit DataScannerViewController wrapped in SwiftUI. Fires once, stops scanning, dismisses sheet, and passes the token back to PairingView.
MobilePairingStoreObservableObject that validates the token and initiates Bonjour handshake. Publishes pairingError: String? for inline error feedback.

Design rules

  • QR path is the primary action — it gets the filled tinted button. Paste-code path is secondary, surfaced below an or-divider.
  • Pair button stays disabled while the field is empty or only whitespace — no silent no-ops.
  • Errors appear inline below the text field, never in a disruptive alert sheet.
  • Scanning fires exactly once per presentation: hasScanned guard prevents a second callback if two frames arrive simultaneously.
  • Camera unavailability (simulator, restricted permission) shows ContentUnavailableView — no crash or empty screen.

Swift

// PairingView — entry point shown when no Mac is paired
struct PairingView: View {
    @EnvironmentObject private var pairingStore: MobilePairingStore
    @State private var tokenInput = ""
    @State private var showingScanner = false

    var body: some View {
        VStack(spacing: MobileSpacing.xl) {
            Spacer()
            Image(systemName: "desktopcomputer.and.iphone")
                .font(.system(size: 64, weight: .light))
                .foregroundColor(.sfTeal)

            // Primary: scan QR
            Button { showingScanner = true } label: {
                Label("Scan QR Code", systemImage: "qrcode.viewfinder")
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, MobileSpacing.md)
                    .background(Color.sfTeal.opacity(0.12))
                    .foregroundColor(.sfTeal)
                    .clipShape(RoundedRectangle(cornerRadius: MobileRadius.md))
            }

            // Secondary: paste code
            TextField("Paste code here", text: $tokenInput, axis: .vertical)
                .font(.system(size: 13, design: .monospaced))
                .lineLimit(4, reservesSpace: true)

            if let error = pairingStore.pairingError {
                Text(error).foregroundColor(.sfOrange).font(MobileFont.caption)
            }

            Button("Pair Device") { pairingStore.pair(token: tokenInput) }
                .disabled(tokenInput.trimmingCharacters(in: .whitespaces).isEmpty)
            Spacer()
        }
        .sheet(isPresented: $showingScanner) {
            QRScannerSheet { scanned in
                tokenInput = scanned
                pairingStore.pair(token: scanned)
            }
        }
    }
}

// QRScannerSheet — VisionKit DataScannerViewController wrapper
struct QRScannerSheet: View {
    var onScan: (String) -> Void
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            QRScannerRepresentable(onScan: { code in onScan(code); dismiss() },
                                   isVisible: true)
            .ignoresSafeArea()
            .navigationTitle("Scan Pairing Code")
            .toolbar { ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") { dismiss() }
            }}
        }
    }
}

Accessibility

  • The pulsing dot is purely decorative — never rely on it alone to convey timer state. The Pause / Resume button label must change with state.
  • Timer readout: add aria-label="Elapsed time: 0 hours 42 minutes 18 seconds" on the digit container, updating every minute (not every second) to avoid excessive announcements.
  • Sync badge: use title attribute (HTML) or accessibilityLabel (SwiftUI) on each badge — e.g. "Synced", "Syncing", "Sync failed".
  • Project color rail is a visual-only affordance. Project name is always present as visible text in the same row.
  • Manual log strip: the dashed "Add" button must have a descriptive accessible label — "Add manual time entry" (not just "Add").
  • Minimum tap target for all row items: 44×44pt. The full log row should be tappable with a 44pt minimum height.