reviewloop 0.2.1

Reproducible, guardrailed automation for academic review workflows on paperreview.ai
Documentation
import SwiftUI
import WidgetKit

// MARK: - Shared helpers

private extension WidgetState.TickHealth {
    var color: Color {
        switch self {
        case .normal: return .green
        case .stale: return .yellow
        case .stuck: return .orange
        case .unknown: return .gray
        }
    }
}

private func relativeLabel(for date: Date?) -> String {
    guard let date else { return "—" }
    let diff = date.timeIntervalSinceNow
    if diff < 0 { return "overdue" }
    let mins = Int(diff / 60)
    return mins < 1 ? "< 1 min" : "in \(mins) min"
}

// MARK: - Unconfigured / error view

private struct UnconfiguredView: View {
    var body: some View {
        VStack(spacing: 6) {
            Image(systemName: "exclamationmark.triangle")
                .font(.title2)
                .foregroundStyle(.secondary)
            Text("No data")
                .font(.headline)
            Text("Run reviewloop daemon")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding()
    }
}

// MARK: - Small widget view

struct WidgetSmallView: View {
    let state: WidgetState

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: 4) {
                Image(systemName: "doc.text.magnifyingglass")
                    .foregroundStyle(.accent)
                Text("ReviewLoop")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Text("\(state.summary.activeCount)")
                .font(.system(size: 44, weight: .bold, design: .rounded))
                .minimumScaleFactor(0.5)
                .lineLimit(1)

            Text("active")
                .font(.caption)
                .foregroundStyle(.secondary)

            Spacer()

            failureBadge
        }
        .padding()
        .containerBackground(for: .widget) {
            Color(.windowBackgroundColor)
        }
    }

    @ViewBuilder
    private var failureBadge: some View {
        if state.summary.failedRecent24h > 0 {
            Label(
                "\(state.summary.failedRecent24h) failed (24h)",
                systemImage: "xmark.circle.fill"
            )
            .font(.caption2)
            .foregroundStyle(.red)
        } else {
            Label("all clear", systemImage: "checkmark.circle.fill")
                .font(.caption2)
                .foregroundStyle(.green)
        }
    }
}

// MARK: - Medium widget view

struct WidgetMediumView: View {
    let state: WidgetState

    private var displayedJobs: [WidgetState.ActiveJob] {
        Array(state.activeJobs.prefix(3))
    }

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            // Left column: summary
            VStack(alignment: .leading, spacing: 6) {
                HStack(spacing: 4) {
                    Image(systemName: "doc.text.magnifyingglass")
                        .foregroundStyle(.accent)
                    Text("ReviewLoop")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                Text("\(state.summary.activeCount)")
                    .font(.system(size: 36, weight: .bold, design: .rounded))
                    .minimumScaleFactor(0.5)
                    .lineLimit(1)

                Text("active")
                    .font(.caption)
                    .foregroundStyle(.secondary)

                Spacer()

                failureBadge
            }
            .frame(maxWidth: 90, alignment: .leading)

            Divider()

            // Right column: job list
            VStack(alignment: .leading, spacing: 4) {
                if displayedJobs.isEmpty {
                    Spacer()
                    Text("No active jobs")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                    Spacer()
                } else {
                    ForEach(displayedJobs) { job in
                        jobRow(job)
                    }
                    Spacer()
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding()
        .containerBackground(for: .widget) {
            Color(.windowBackgroundColor)
        }
    }

    private func jobRow(_ job: WidgetState.ActiveJob) -> some View {
        HStack(spacing: 6) {
            Image(systemName: "circle.fill")
                .font(.system(size: 6))
                .foregroundStyle(.blue)
            VStack(alignment: .leading, spacing: 1) {
                Text(job.paperId)
                    .font(.caption)
                    .fontWeight(.medium)
                    .lineLimit(1)
                HStack(spacing: 4) {
                    Text(job.status)
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                    Text("·")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                    Text(relativeLabel(for: job.nextPollAt))
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }
            }
        }
    }

    @ViewBuilder
    private var failureBadge: some View {
        if state.summary.failedRecent24h > 0 {
            Label(
                "\(state.summary.failedRecent24h) failed",
                systemImage: "xmark.circle.fill"
            )
            .font(.caption2)
            .foregroundStyle(.red)
        } else {
            Label("all clear", systemImage: "checkmark.circle.fill")
                .font(.caption2)
                .foregroundStyle(.green)
        }
    }
}

// MARK: - Entry view dispatcher

struct ReviewLoopEntryView: View {
    @Environment(\.widgetFamily) private var family
    let entry: ReviewLoopEntry

    var body: some View {
        switch entry.result {
        case .success(let state):
            switch family {
            case .systemMedium:
                WidgetMediumView(state: state)
            default:
                WidgetSmallView(state: state)
            }
        case .failure:
            UnconfiguredView()
        }
    }
}