Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="docs/images/menubar.png" width="720" alt="Menu bar kanban panel">
</p>

**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.

<p align="center">
<img src="docs/images/widget.png" width="380" alt="Notification Center widget">
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
133 changes: 124 additions & 9 deletions Sources/App/BoardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
}
33 changes: 28 additions & 5 deletions Sources/Shared/GitHubAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,22 @@ 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 {
let text: String?
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?
}
Expand All @@ -210,16 +215,16 @@ 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 } }
fieldValues(first:30){ nodes {
... 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 } } }
} }
} }
Expand All @@ -246,23 +251,41 @@ 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) }
else if let n = v.number { values[name] = .num(n) }
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
?? "🔒 内容を取得できません(リポジトリ読み取り権限が必要)",
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
)
}

Expand Down
60 changes: 60 additions & 0 deletions Sources/Shared/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
19 changes: 16 additions & 3 deletions Sources/Widget/BoardWidgetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) 件")
Expand Down
6 changes: 4 additions & 2 deletions docs/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@

## スクリーンショット

**メニューバーパネル** — カンバンボード。カードを**ドラッグ**して列(Status)を変更、**右クリック**でもStatus変更、**「+」**でタスク追加、カードをクリックでGitHubを開きます。
**メニューバーパネル** — カンバンボード。カードを**ドラッグ**して列(Status)を変更、**右クリック**でもStatus変更、**「+」**でタスク追加、カードをクリックでGitHubを開きます。各カードには**ラベル・Priority・End Date**も表示されます。

<p align="center">
<img src="images/menubar.png" width="720" alt="メニューバーのカンバンパネル">
</p>

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

<p align="center">
<img src="images/widget.png" width="380" alt="通知センターのウィジェット">
Expand All @@ -29,6 +29,7 @@
- メニューバーの**カンバンボード**: 閲覧・**ドラッグ&ドロップでStatus変更**・タスク追加(ドラフトIssue)・GitHubで開く
- **GitHubと同じ並び**: プロジェクトビューで設定したソート順を再現
- **Status色**: 各カードにStatusの色ドットを表示
- **カードの詳細**: 各カードにラベル・Priority・End Dateを表示(Priorityはウィジェットにも表示)
- **インタラクティブなウィジェット**: Statusを選んでタスク一覧を表示(読み取り専用・自動更新)
- **メニューバー常駐**: Dockアイコンなし。アイコンからパネルが開きます

Expand Down Expand Up @@ -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 でビルドが必要です。
Loading