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
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.
| Layer | Value | Purpose |
|---|---|---|
| Background | Brand dark (#16302A) | Strong brand presence; reverse-coloured text on top |
| Grain texture | radial-gradient dot at 3px grid, 4% opacity | Adds tactile depth without visible pattern at small sizes |
| Large title | 34px / 700 / −0.022em tracking | Maps to iOS .largeTitle; feels native and spacious |
| Week summary card | rgba(white, 0.06) fill + rgba(white, 0.10) stroke, 14px radius | Glassmorphic surface on the dark header — stays legible in both themes |
| Card overlap | margin-top: −14px on the timer card | Visually 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.
| Element | Spec |
|---|---|
| WeekRing | 64×64pt SVG, 6pt stroke, accent color, round linecap, rotated −90° so 0% starts at 12 o'clock |
| Percentage label | 18px bold + 10px % superscript, monospaced digits, white on dark |
| WeekBars | 8px wide × up to 32px tall bars, 6px gap, today's bar in accent color, others at rgba(white, 0.32) |
| Delta | Pro-rata: logged − (goal × days_elapsed/7). +/− prefix, accent color text |
| Eyebrow | 10.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.
| Row | Contents | Interaction |
|---|---|---|
| Selectors | Project color swatch (8px) + name + chevron | Task type + chevron | Full-cell tap area; each half opens a picker/menu |
| Note | Single-line text; placeholder when empty | Tapping opens an edit sheet or inline text field |
| Timer row | Pulsing dot + monospaced HH:MM:SS + Pause/Resume button | Button 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 0over 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.
| Button | Border | Label | Flex |
|---|---|---|---|
| Add manual entry | 1.5px dashed, --border-strong | Plus icon + label, full width | flex: 1 — expands to fill remaining space |
| Resume… | 1px solid, --border-strong | Checkmark icon + label, fixed width | flex: 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.
| Part | Description |
|---|---|
| Section header | UPPERCASE date label + entry count (subdued) + right-aligned total (monospaced, brand ink) |
| Card container | 14px radius, 1px border, white background — groups rows into a cohesive visual unit |
| Color rail | 4px wide × full row height, 2px radius, project swatch color — instant project identification |
| Project + type | 14px semibold project name + 10px UPPERCASE task type with · separator, text-tertiary |
| Note | 13px regular, text-secondary, single line with ellipsis truncation |
| Meta row | 11px time range (monospaced) + 3px dot separator + billable/internal status |
| Duration | 16px semibold, right-aligned, monospaced digits |
| Sync badge | 13px 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).
| State | Icon | Color | Meaning |
|---|---|---|---|
| synced | Circle with checkmark | #5C8C5A (moss) | Entry has been pushed to the remote timesheet |
| pending | Circular arrow (refresh) | --text-tertiary | Sync in progress or queued |
| failed | Circle 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.
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.
"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.
| Component | Description |
|---|---|
PairingView | Full-screen onboarding view. Centred icon + instructions, primary QR button, divider, monospaced paste-code field, disabled-until-valid Pair button. |
QRScannerSheet | VisionKit DataScannerViewController wrapped in SwiftUI. Fires once, stops scanning, dismisses sheet, and passes the token back to PairingView. |
MobilePairingStore | ObservableObject 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.