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.
@@ -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**も表示されます。
@@ -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 でビルドが必要です。