From 8f47fa37f5193672e3a0ff0e4cb4c8d03903bd5c Mon Sep 17 00:00:00 2001 From: NaoyaTatetsu Date: Sun, 19 Jul 2026 01:28:57 +0900 Subject: [PATCH 1/2] Display labels, priority, and end date on project cards Updated the data model and API queries to fetch labels, priority, and end date fields. Added a custom FlowLayout to render label chips on cards and included priority and date metadata in both the app's board view and the widget. --- Sources/App/BoardView.swift | 133 +++++++++++++++++++++++++-- Sources/Shared/GitHubAPI.swift | 33 ++++++- Sources/Shared/Models.swift | 60 ++++++++++++ Sources/Widget/BoardWidgetView.swift | 19 +++- 4 files changed, 228 insertions(+), 17 deletions(-) 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) 件") From cbff1d6de2fc56af69584a1fcb5199173030c6c6 Mon Sep 17 00:00:00 2001 From: NaoyaTatetsu Date: Sun, 19 Jul 2026 01:31:00 +0900 Subject: [PATCH 2/2] Update documentation to include card details Document the inclusion of labels, Priority, and End Date fields on cards. Added configuration requirements for the Priority and End Date fields and noted constraints regarding draft issue labels. --- README.md | 8 +++++++- docs/README.ja.md | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) 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/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 でビルドが必要です。