diff --git a/README.md b/README.md index fdedac0..6bc927f 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ **Menu bar panel** — a kanban board. Drag cards between columns to change Status, right-click to change Status, "+" to add a task, click a card to open it on GitHub. +Each card also shows its labels, Priority, and End Date.

Menu bar kanban panel

-**Widget** — status buttons across the top; tap one to show that status's tasks. +**Widget** — status buttons across the top; tap one to show that status's tasks, +each with its Priority and End Date.

Notification Center widget @@ -31,6 +33,7 @@ right-click to change Status, "+" to add a task, click a card to open it on GitH - **Kanban board** in the menu bar: view, drag-and-drop Status changes, add tasks (draft issues), open items on GitHub. - **Sorted like GitHub**: cards follow the project view's configured sort. - **Status colors**: each card shows its Status color dot. +- **Card details**: labels, Priority, and End Date shown on each card (Priority in the widget too). - **Interactive widget**: pick a status, see its tasks — read-only, auto-refreshing. - **Menu-bar-only**: no Dock icon; the panel drops down from the menu bar. @@ -110,6 +113,9 @@ xcodegen generate && open GitHubProjectMenuBar.xcodeproj - **Manual drag order** (no configured sort) can't be reproduced — the GitHub API doesn't expose it. Only **field-based sorts** match. +- **Card fields**: Priority reads from a single-select field named `Priority`, and + End Date from a date field named `End Date` (both case-insensitive). Labels come + from the issue/PR itself, so **draft issues show no labels**. - The widget is **read-only** (WidgetKit); it refreshes on a timeline, and status switching renders instantly from a cache. - Built for **personal use on your own Mac** with a free team — not for diff --git a/Sources/App/BoardView.swift b/Sources/App/BoardView.swift index 5780e21..486ff6a 100644 --- a/Sources/App/BoardView.swift +++ b/Sources/App/BoardView.swift @@ -98,15 +98,39 @@ private struct CardView: View { let board: Board? var body: some View { - HStack(alignment: .top, spacing: 6) { - Circle() - .fill(statusColor(colorEnum)) - .frame(width: 8, height: 8) - .padding(.top, 3) - Text(card.title) - .font(.caption) - .lineLimit(3) - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .top, spacing: 6) { + Circle() + .fill(statusColor(colorEnum)) + .frame(width: 8, height: 8) + .padding(.top, 3) + Text(card.title) + .font(.caption) + .lineLimit(3) + Spacer(minLength: 0) + } + + if !card.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(card.labels, id: \.name) { LabelChipView(label: $0) } + } + } + + if card.priority != nil || card.endDate != nil { + HStack(spacing: 6) { + if let priority = card.priority { + PriorityChipView(name: priority, colorEnum: card.priorityColor) + } + Spacer(minLength: 0) + if let end = card.endDate { + HStack(spacing: 3) { + Image(systemName: "calendar").font(.system(size: 9)) + Text(formatProjectDate(end)).font(.system(size: 10)) + } + .foregroundStyle(.secondary) + } + } + } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) @@ -133,3 +157,94 @@ private struct CardView: View { board?.statusOptions.first(where: { $0.id == card.statusOptionId })?.color } } + +// MARK: - Card metadata chips + +private struct LabelChipView: View { + let label: CardLabel + + var body: some View { + let color = Color(hex: label.color) ?? .secondary + Text(label.name) + .font(.system(size: 9)) + .lineLimit(1) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(color.opacity(0.22), in: Capsule()) + .overlay(Capsule().strokeBorder(color.opacity(0.5), lineWidth: 0.5)) + .foregroundStyle(color) + } +} + +private struct PriorityChipView: View { + let name: String + let colorEnum: String? + + var body: some View { + let color = statusColor(colorEnum) + Text(name) + .font(.system(size: 9)).bold() + .lineLimit(1) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(color.opacity(0.22), in: Capsule()) + .foregroundStyle(color) + } +} + +private extension Color { + /// Build a Color from a GitHub hex string like "d73a4a" (with or without "#"). + init?(hex: String?) { + guard var s = hex else { return nil } + s = s.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("#") { s.removeFirst() } + guard s.count == 6, let v = UInt64(s, radix: 16) else { return nil } + self.init( + red: Double((v >> 16) & 0xFF) / 255, + green: Double((v >> 8) & 0xFF) / 255, + blue: Double(v & 0xFF) / 255) + } +} + +// MARK: - Wrapping layout for labels + +/// Left-to-right flow layout that wraps subviews to the next line when they +/// exceed the available width (used for the card's label chips). +private struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0, maxRowWidth: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth, x > 0 { + maxRowWidth = max(maxRowWidth, x - spacing) + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + maxRowWidth = max(maxRowWidth, x - spacing) + return CGSize(width: min(maxRowWidth, maxWidth), height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.width, x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + subview.place( + at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), + anchor: .topLeading, proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/Sources/Shared/GitHubAPI.swift b/Sources/Shared/GitHubAPI.swift index 5e45058..0b70be8 100644 --- a/Sources/Shared/GitHubAPI.swift +++ b/Sources/Shared/GitHubAPI.swift @@ -177,10 +177,13 @@ public struct GitHubAPI: Sendable { let title: String? let number: Int? let url: String? + let labels: LabelConn? enum CodingKeys: String, CodingKey { - case typename = "__typename", title, number, url + case typename = "__typename", title, number, url, labels } } + struct LabelConn: Decodable { let nodes: [LabelNode] } + struct LabelNode: Decodable { let name: String; let color: String? } struct StatusValue: Decodable { let optionId: String? } struct FieldValues: Decodable { let nodes: [FieldValueNode] } struct FieldValueNode: Decodable { @@ -188,6 +191,8 @@ public struct GitHubAPI: Sendable { let number: Double? let date: String? let optionId: String? + let name: String? // single-select option name + let color: String? // single-select option color enum let startDate: String? let field: FieldNameOnly? } @@ -210,8 +215,8 @@ public struct GitHubAPI: Sendable { items(first:100){ nodes { id content { __typename - ... on Issue { title number url } - ... on PullRequest { title number url } + ... on Issue { title number url labels(first:10){ nodes { name color } } } + ... on PullRequest { title number url labels(first:10){ nodes { name color } } } ... on DraftIssue { title } } fieldValueByName(name:"Status"){ ... on ProjectV2ItemFieldSingleSelectValue { optionId } } @@ -219,7 +224,7 @@ public struct GitHubAPI: Sendable { ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { name } } } ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { name } } } ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { name } } } - ... on ProjectV2ItemFieldSingleSelectValue { optionId field { ... on ProjectV2FieldCommon { name } } } + ... on ProjectV2ItemFieldSingleSelectValue { optionId name color field { ... on ProjectV2FieldCommon { name } } } ... on ProjectV2ItemFieldIterationValue { startDate field { ... on ProjectV2FieldCommon { name } } } } } } } @@ -246,6 +251,9 @@ public struct GitHubAPI: Sendable { var valuesByItem: [String: [String: SortVal]] = [:] let cards = node.items.nodes.map { item -> Card in var values: [String: SortVal] = [:] + var priority: String? + var priorityColor: String? + var endDate: String? for v in item.fieldValues?.nodes ?? [] { guard let name = v.field?.name else { continue } if let t = v.text { values[name] = .str(t) } @@ -253,8 +261,19 @@ public struct GitHubAPI: Sendable { else if let d = v.date { values[name] = .str(d) } else if let o = v.optionId { values[name] = .str(o) } else if let s = v.startDate { values[name] = .str(s) } + + if name.caseInsensitiveCompare("Priority") == .orderedSame, let optName = v.name { + priority = optName + priorityColor = v.color + } + if name.caseInsensitiveCompare("End Date") == .orderedSame, let d = v.date { + endDate = d + } } valuesByItem[item.id] = values + let labels = (item.content?.labels?.nodes ?? []).map { + CardLabel(name: $0.name, color: $0.color) + } return Card( itemId: item.id, title: item.content?.title @@ -262,7 +281,11 @@ public struct GitHubAPI: Sendable { number: item.content?.number, url: item.content?.url, statusOptionId: item.fieldValueByName?.optionId, - kind: CardKind(typename: item.content?.typename) + kind: CardKind(typename: item.content?.typename), + labels: labels, + priority: priority, + priorityColor: priorityColor, + endDate: endDate ) } diff --git a/Sources/Shared/Models.swift b/Sources/Shared/Models.swift index d1742ad..df69605 100644 --- a/Sources/Shared/Models.swift +++ b/Sources/Shared/Models.swift @@ -26,6 +26,18 @@ public enum CardKind: String, Codable, Sendable { } } +/// An issue/PR label shown on a card. +public struct CardLabel: Hashable, Codable, Sendable { + public let name: String + /// GitHub label hex color, e.g. "d73a4a" (no leading "#"), may be nil. + public let color: String? + + public init(name: String, color: String?) { + self.name = name + self.color = color + } +} + public struct Card: Identifiable, Hashable, Codable, Sendable { public var id: String { itemId } public let itemId: String @@ -34,6 +46,54 @@ public struct Card: Identifiable, Hashable, Codable, Sendable { public let url: String? public var statusOptionId: String? public let kind: CardKind + public let labels: [CardLabel] + /// Priority option name (e.g. "P0"), from the "Priority" single-select field. + public let priority: String? + /// Priority option's GitHub single-select color enum (BLUE/RED/…). + public let priorityColor: String? + /// End date ("End date" field), ISO "yyyy-MM-dd", date only. + public let endDate: String? + + public init( + itemId: String, title: String, number: Int?, url: String?, + statusOptionId: String?, kind: CardKind, + labels: [CardLabel] = [], priority: String? = nil, + priorityColor: String? = nil, endDate: String? = nil + ) { + self.itemId = itemId + self.title = title + self.number = number + self.url = url + self.statusOptionId = statusOptionId + self.kind = kind + self.labels = labels + self.priority = priority + self.priorityColor = priorityColor + self.endDate = endDate + } +} + +// MARK: - Date field formatting + +private let projectDateParser: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone(identifier: "UTC") + return f +}() + +private let projectDateDisplay: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .none + return f +}() + +/// Format a project date field value ("yyyy-MM-dd") for display, date-only. +public func formatProjectDate(_ raw: String) -> String { + guard let date = projectDateParser.date(from: raw) else { return raw } + return projectDateDisplay.string(from: date) } public struct Board: Codable, Sendable { diff --git a/Sources/Widget/BoardWidgetView.swift b/Sources/Widget/BoardWidgetView.swift index a3a2c7e..7769e0b 100644 --- a/Sources/Widget/BoardWidgetView.swift +++ b/Sources/Widget/BoardWidgetView.swift @@ -56,9 +56,22 @@ struct BoardWidgetView: View { Text("タスクなし").font(.caption2).foregroundStyle(.secondary) } else { ForEach(cards.prefix(10)) { card in - Text("• \(card.title)") - .font(.system(size: 13)) - .lineLimit(1) + HStack(spacing: 4) { + Text("• \(card.title)") + .font(.system(size: 13)) + .lineLimit(1) + Spacer(minLength: 4) + if let priority = card.priority { + Text(priority) + .font(.system(size: 10)).bold() + .foregroundStyle(statusColor(card.priorityColor)) + } + if let end = card.endDate { + Text(formatProjectDate(end)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } } if cards.count > 10 { Text("他 \(cards.count - 10) 件") diff --git a/docs/README.ja.md b/docs/README.ja.md index 151ab65..ee7a576 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -12,13 +12,13 @@ ## スクリーンショット -**メニューバーパネル** — カンバンボード。カードを**ドラッグ**して列(Status)を変更、**右クリック**でもStatus変更、**「+」**でタスク追加、カードをクリックでGitHubを開きます。 +**メニューバーパネル** — カンバンボード。カードを**ドラッグ**して列(Status)を変更、**右クリック**でもStatus変更、**「+」**でタスク追加、カードをクリックでGitHubを開きます。各カードには**ラベル・Priority・End Date**も表示されます。

メニューバーのカンバンパネル

-**ウィジェット** — 上部のStatusボタンをタップすると、そのStatusのタスクが表示されます。 +**ウィジェット** — 上部のStatusボタンをタップすると、そのStatusのタスクが表示されます(各タスクにPriority・End Dateを併記)。

通知センターのウィジェット @@ -29,6 +29,7 @@ - メニューバーの**カンバンボード**: 閲覧・**ドラッグ&ドロップでStatus変更**・タスク追加(ドラフトIssue)・GitHubで開く - **GitHubと同じ並び**: プロジェクトビューで設定したソート順を再現 - **Status色**: 各カードにStatusの色ドットを表示 +- **カードの詳細**: 各カードにラベル・Priority・End Dateを表示(Priorityはウィジェットにも表示) - **インタラクティブなウィジェット**: Statusを選んでタスク一覧を表示(読み取り専用・自動更新) - **メニューバー常駐**: Dockアイコンなし。アイコンからパネルが開きます @@ -103,5 +104,6 @@ xcodegen generate && open GitHubProjectMenuBar.xcodeproj ## 補足・既知の制約 - **手動ドラッグの並び順**(ソート未設定)はGitHub APIが公開しておらず再現できません。ビューで**フィールドソート**を設定している場合のみ順序が一致します。 +- **カードのフィールド**: Priorityは単一選択フィールド `Priority`、End Dateは日付フィールド `End Date` から取得します(いずれも大文字小文字は区別しません)。ラベルはIssue/PR本体由来のため、**ドラフトIssueにはラベルが表示されません**。 - ウィジェットは WidgetKit の仕様上**読み取り専用**で、更新はタイムラインに依存します。Statusボタンの切り替えはキャッシュから即時描画されます。 - **自分のMac専用**(無料Team)。配布は不可で、他人が使うには各自の Apple ID / Team ID でビルドが必要です。