下記記事で作成したGUIアプリの機能を拡張してみようと思います。
PowerShell + WPF で作る「JSON 駆動ダッシュボード & アプリランチャー」
はじめに 今回紹介するのは、Windows PowerShell 5.x 環境でも動く WPF GUI ダッシュボードです。XAML で画面を定義し、PowerShell から XamlReader を使ってロード。データはすべて **JSON ファイル(cards.json)**で管理します。また、サービスの稼働監視・起動停止に加えて、アプリケーションランチャーもタブで統合しました。.psm1(PowerShell モジュール)にすると「再利用・分離・管理」が段違いでやりやすくなるため、psm1でモジュ ...
この記事では、既存の WPF×PowerShell 製 GUI に タスク JSON を読み込み、DataGrid に表示&絞り込み(期間/キーワード) できる機能を追加する手順をまとめます。
実装モジュールは modules/TaskData.psm1
、呼び出しは Main.ps1
、UI は XAML の DataGrid+フィルタバーで構成します。
ゴール(できること)
- JSON(
TASK_DATA
)を読み込み、ObservableCollection として DataGrid にバインド - inquery_date / due_date を基に 期間フィルタ(From/To)
- タスク名の部分一致による キーワードフィルタ
- 本日件数と、昨日との差分をカウントカードで表示
- ハイパーリンク列(タスク名をクリックで URL を開く)
前提
- PowerShell 5.x(STA 実行)
- WPF(
PresentationCore/PresentationFramework/WindowsBase
) - 既存のアプリ側で以下ユーティリティがある想定
Load-Xaml
,Bind-DataContext
,Register-HyperlinkHandler
,New-UiTimer
など
PowerShell + WPF で作る「JSON 駆動ダッシュボード & アプリランチャー」
はじめに 今回紹介するのは、Windows PowerShell 5.x 環境でも動く WPF GUI ダッシュボードです。XAML で画面を定義し、PowerShell から XamlReader を使ってロード。データはすべて **JSON ファイル(cards.json)**で管理します。また、サービスの稼働監視・起動停止に加えて、アプリケーションランチャーもタブで統合しました。.psm1(PowerShell モジュール)にすると「再利用・分離・管理」が段違いでやりやすくなるため、psm1でモジュ ...
/YourAppRoot
├─ Main.ps1
├─ modules/
│ ├─ UiHelpers.psm1
│ ├─ JsonData.psm1
│ ├─ Navigation.psm1
│ ├─ ServiceMonitor.psm1
│ ├─ AppTimers.psm1
│ ├─ AppLauncher.psm1
│ ├─ AppIcons.psm1
│ └─ TaskData.psm1 ← 本記事で追加
└─ Views/
└─ MainWindow.xaml ← DataGrid とフィルタバーを定義
modules/TaskData.psm1
役割とポイント
- Parse-Date: いくつかの書式を許容して
DateTime
に変換 - Read-TaskJson: JSON を読み込み
ConvertFrom-Json
- Convert-Tasks: 表示用の ObservableCollection を生成(表示文字列も用意)
- Ensure-TasksCollectionView: DataGrid の ItemsSource を 必ず ICollectionView 化
- Apply/Clear-TaskFilter: 期間・キーワードの絞り込みと解除
- Initialize-TaskView: 初期読み込みとカウントカード更新
- Update-TaskView: 再読み込み&件数反映
ICollectionView
を必ず介してフィルタ(CollectionViewSource.GetDefaultView
)
# modules/TaskData.psm1 (PowerShell 5.x 対応・ICollectionView/引数修正版 + ダッシュボード統合 + ステータスフィルタ) # 既定表示: "taskStatus が '完了' 以外" # UI 側に ComboBox x:Name="TaskStatusFilter" があれば、ステータス候補(JSONから自動抽出)を自動バインド # --- WPF アセンブリ --- Add-Type -AssemblyName PresentationCore, PresentationFramework, WindowsBase -ErrorAction SilentlyContinue # キャッシュ(ダッシュボード連携用) $script:LastTaskList = $null $script:LastTaskJsonPath = $null $script:StatusChoices = $null # ObservableCollection[string] $script:CurrentStatusFilter= '(完了以外)' $now = Get-Date # ---------------- 共通ヘルパ ---------------- function Find-Ui { param( [Parameter(Mandatory)][Windows.Window]$Window, [Parameter(Mandatory)][string]$Name ) $el = $Window.FindName($Name) if (-not $el) { # ContentControl 配下など別 NameScope も論理ツリーで探索 $el = [System.Windows.LogicalTreeHelper]::FindLogicalNode($Window, $Name) } return $el } function Parse-Date { param([string]$s) if (-not $s) { return $null } $styles = [System.Globalization.DateTimeStyles]::AssumeLocal $cult = [System.Globalization.CultureInfo]::InvariantCulture $formats = @('yyyy/MM/dd','yyyy-MM-dd','yyyy-MM-ddTHH:mm:ssK','yyyy/MM/dd HH:mm:ss') foreach ($f in $formats) { try { return [datetime]::ParseExact($s, $f, $cult, $styles) } catch {} } try { return [datetime]::Parse($s, $cult) } catch { return $null } } function Convert-ToDateOrNull { param([object]$v) if ($null -eq $v) { return $null } try { return [datetime]$v } catch { return $null } } function Read-TaskJson { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } catch { return $null } } function Convert-Tasks { param($doc) $list = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]' write-host $doc.TASK_DATA if ($doc -and $doc.TASK_DATA) { foreach ($t in $doc.TASK_DATA) { $inq = Parse-Date $t.inquery_date $due = Parse-Date $t.due_date $inqDisplay = ''; if ($inq) { $inqDisplay = $inq.ToString('yyyy-MM-dd') } $dueDisplay = ''; if ($due) { $dueDisplay = $due.ToString('yyyy-MM-dd') } $obj = [PSCustomObject]@{ id = [string]$t.task_id name = [string]$t.task_name url = [string]$t.task_URL InqueryDate = $inq DueDate = $due inqDisplay = $inqDisplay dueDisplay = $dueDisplay taskStatus = $t.status } [void]$list.Add($obj) } } write-host $list return $list } # --- DataGrid / View ユーティリティ --- function Get-TasksGrid { param([Windows.Window]$Window) $grid = Find-Ui -Window $Window -Name 'DailyTasksGrid' if ($grid) { return $grid } return $null } function Ensure-TasksCollectionView { param([Windows.Window]$Window) $grid = Find-Ui -Window $Window -Name 'DailyTasksGrid' if (-not $grid) { return $null } if (-not $grid.ItemsSource) { $empty = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]' $grid.ItemsSource = $empty } $src = $grid.ItemsSource $view = [System.Windows.Data.CollectionViewSource]::GetDefaultView($src) if ($view -is [System.ComponentModel.ICollectionView]) { return $view } if ($src -is [System.Collections.IList]) { $lcw = New-Object System.Windows.Data.ListCollectionView -ArgumentList (,$src) $grid.ItemsSource = $lcw } elseif ($src -is [System.Collections.IEnumerable]) { $arr = New-Object System.Collections.ArrayList foreach ($i in $src) { [void]$arr.Add($i) } $lcw = New-Object System.Windows.Data.ListCollectionView -ArgumentList (,$arr) $grid.ItemsSource = $lcw } else { $wrapped = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]' [void]$wrapped.Add($src) $lcw = New-Object System.Windows.Data.ListCollectionView -ArgumentList (,$wrapped) $grid.ItemsSource = $lcw } $view2 = [System.Windows.Data.CollectionViewSource]::GetDefaultView($grid.ItemsSource) return $view2 } function Set-CountFromView { param([Windows.Window]$Window, $view, [int]$fallbackCount) $cnt = $fallbackCount if ($view -is [System.ComponentModel.ICollectionView]) { $c = 0; foreach ($i in $view) { $c++ }; $cnt = $c } elseif ($view -is [System.Collections.IEnumerable]) { $c = 0; foreach ($i in $view) { $c++ }; $cnt = $c } $num = Find-Ui -Window $Window -Name 'DailyTasksCountNum' if ($num) { $num.Text = [string]$cnt } } # ---------------- ステータス候補の構築・UI反映 ---------------- function Build-StatusChoicesFrom { param([System.Collections.IEnumerable]$source) $set = New-Object 'System.Collections.Generic.HashSet[string]' if ($source) { foreach ($x in $source) { if ($x -and $x.PSObject.Properties['taskStatus']) { $s = [string]$x.taskStatus if (-not [string]::IsNullOrWhiteSpace($s)) { [void]$set.Add($s) } } } } $ordered = @('(完了以外)','(すべて)') + ($set | Sort-Object) $oc = New-Object 'System.Collections.ObjectModel.ObservableCollection[string]' foreach ($s in $ordered) { [void]$oc.Add($s) } return $oc } function Apply-StatusChoicesToUI { param([Windows.Window]$Window) $cmb = Find-Ui -Window $Window -Name 'TaskStatusFilter' if (-not $cmb) { return } $cmb.ItemsSource = $script:StatusChoices # 既存の選択が候補に無ければ既定へ $target = $script:CurrentStatusFilter $exists = $false foreach ($it in $script:StatusChoices) { if ($it -eq $target) { $exists = $true; break } } if (-not $exists) { $target = '(完了以外)' } $cmb.SelectedItem = $target } # ---------------- タスクビュー初期化・更新 ---------------- function Initialize-TaskView { param([Windows.Window]$Window, [string]$TaskJsonPath) $doc = Read-TaskJson -Path $TaskJsonPath write-host $doc $list = Convert-Tasks -doc $doc # ダッシュボード用キャッシュ $script:LastTaskList = $list $script:LastTaskJsonPath = $TaskJsonPath # ステータス候補を構築(JSONから自動抽出) $script:StatusChoices = Build-StatusChoicesFrom -source $list $script:CurrentStatusFilter = '(完了以外)' $grid = Get-TasksGrid -Window $Window if ($grid) { $grid.ItemsSource = $list } # KPI: 今日/昨日/今月/先月 $num = Find-Ui -Window $Window -Name 'DailyTasksCountNum'; $todayInqTask = @() $yesterdayInqTask = @() $thisMonthInqTask = @() $preMonthInqTask = @() $diffTaskCnt = 0 if ($num) { foreach ($rec in $list){ if ($rec.InqueryDate -ne $null) { if ($rec.InqueryDate.ToString('yyyy-MM-dd') -eq $now.ToString('yyyy-MM-dd')){ $todayInqTask += $rec } if ($rec.InqueryDate.ToString('yyyy-MM-dd') -eq $now.AddDays(-1).ToString('yyyy-MM-dd')){ $yesterdayInqTask += $rec } if ($rec.InqueryDate.ToString('yyyy-MM') -eq $now.ToString('yyyy-MM')){ $thisMonthInqTask += $rec } if ($rec.InqueryDate.ToString('yyyy-MM') -eq $now.AddMonths(-1).ToString('yyyy-MM')){ $preMonthInqTask += $rec } } } $num.Text = [string]$todayInqTask.Count $diffTaskCnt = $todayInqTask.Count - $yesterdayInqTask.Count } $delta = Find-Ui -Window $Window -Name 'DailyTasksCountDelta'; if ($delta) { $delta.Text = [string]$diffTaskCnt } # ステータスコンボをUIへ反映 Apply-StatusChoicesToUI -Window $Window Set-DefaultCalendarToThisMonth -Window $Window $script:CurrentStatusFilter = '(完了以外)' # 初期表示は「完了以外」になるように張り直し Rebind-TaskView -Window $Window } function Update-TaskView { param([Windows.Window]$Window, [string]$TaskJsonPath) $grid = Get-TasksGrid -Window $Window if (-not $grid) { return } $doc = Read-TaskJson -Path $TaskJsonPath $list = Convert-Tasks -doc $doc $grid.ItemsSource = $list # キャッシュ更新 $script:LastTaskList = $list $script:LastTaskJsonPath = $TaskJsonPath # ステータス候補を再構築(新しいJSONに追従) $script:StatusChoices = Build-StatusChoicesFrom -source $list Apply-StatusChoicesToUI -Window $Window $view = Ensure-TasksCollectionView -Window $Window if ($view -is [System.ComponentModel.ICollectionView]) { $view.Refresh() } Set-CountFromView -Window $Window -view $view -fallbackCount $list.Count # 現在の選択状態で再バインド Rebind-TaskView -Window $Window } # ---------------- フィルタ ---------------- $script:TaskFilterDebug = $true # ====== ユーティリティ:UI からパラメータを読む ====== function Get-TaskFilterParams { param([Windows.Window]$Window) $fieldSel = Find-Ui -Window $Window -Name 'TaskFilterField' $fromDP = Find-Ui -Window $Window -Name 'TaskFilterFrom' $toDP = Find-Ui -Window $Window -Name 'TaskFilterTo' $kwBox = Find-Ui -Window $Window -Name 'TaskFilterKeyword' $statusCmb= Find-Ui -Window $Window -Name 'TaskStatusFilter' # inquery_date / due_date -> プロパティ名 $field = 'InqueryDate' if ($fieldSel) { $chosen = $null if ($fieldSel.SelectedItem -and $fieldSel.SelectedItem -is [System.Windows.Controls.ComboBoxItem]) { $chosen = ($fieldSel.SelectedItem.Content).ToString() } elseif ($fieldSel.SelectedValue) { $chosen = $fieldSel.SelectedValue.ToString() } elseif ($fieldSel.Text) { $chosen = $fieldSel.Text.ToString() } if ($chosen) { $chosen = $chosen.Trim().ToLower() if ($chosen -eq 'due_date') { $field = 'DueDate' } else { $field = 'InqueryDate' } } } $from = $null; if ($fromDP -and $fromDP.SelectedDate) { $from = $fromDP.SelectedDate.Date } $to = $null; if ($toDP -and $toDP.SelectedDate) { $to = $toDP.SelectedDate.Date } $kw = ''; if ($kwBox) { $kw = [string]$kwBox.Text } $status = $script:CurrentStatusFilter if ($statusCmb) { $sel = $null if ($statusCmb.SelectedItem) { $sel = [string]$statusCmb.SelectedItem } if (-not [string]::IsNullOrWhiteSpace($sel)) { $status = $sel } } return [pscustomobject]@{ Field = $field From = $from To = $to Kw = $kw.Trim() Status = $status } } # ====== 元データを条件で絞って“新しいビュー(ItemsSource)”を構築 ====== function Build-FilteredTaskItems { param( [object[]]$Source, # 既存キャッシュ($script:LastTaskList) [string]$Field = 'InqueryDate', [Nullable[datetime]]$From = $null, [Nullable[datetime]]$To = $null, [string]$Kw = '', [string]$Status = '(完了以外)' # ← 既定は完了以外 ) $arr = @() if (-not $Source) { return ,$arr } foreach ($row in $Source) { # --- ステータス --- $st = $null if ($row.PSObject.Properties['taskStatus']) { $st = [string]$row.taskStatus } $statusPass = $true if ($Status -eq '(完了以外)') { if ($st -eq '完了') { $statusPass = $false } } elseif ($Status -eq '(すべて)') { $statusPass = $true } else { $statusPass = ($st -eq $Status) } if (-not $statusPass) { continue } # --- キーワード --- if ($Kw -and $Kw.Length -gt 0) { $name = '' if ($row.PSObject.Properties['name']) { $name = [string]$row.name } if ($name -notlike ('*' + $Kw + '*')) { continue } } # --- 日付 --- if ($Field -ne 'DueDate') { $Field = 'InqueryDate' } $d = $null if ($Field -eq 'DueDate' -and $row.PSObject.Properties['DueDate']) { $d = $row.DueDate } if ($Field -eq 'InqueryDate' -and $row.PSObject.Properties['InqueryDate']) { $d = $row.InqueryDate } if ($From -ne $null -or $To -ne $null) { if ($null -eq $d) { continue } # 日付なしは除外 $dd = $d.Date if ($From -ne $null -and $dd -lt $From) { continue } if ($To -ne $null -and $dd -gt $To) { continue } } $arr += $row } # 並び(選択フィールドの降順) if ($Field -eq 'DueDate') { $arr = $arr | Sort-Object -Property DueDate -Descending } else { $arr = $arr | Sort-Object -Property InqueryDate -Descending } return @($arr) } # 単体や null でも必ず IEnumerable にする(文字列は除外) function Ensure-Enumerable { param([object]$value) if ($null -eq $value) { return @() } if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { return $value # 既に列挙体(配列/OC/List 等)はそのまま } return @($value) # 単体を1要素配列に } # IEnumerable -> ObservableCollection[object] に“確実に”変換 function To-ObservableCollection { param([object]$enumerable) $list = New-Object 'System.Collections.Generic.List[object]' foreach ($i in (Ensure-Enumerable $enumerable)) { [void]$list.Add($i) } return New-Object 'System.Collections.ObjectModel.ObservableCollection[object]' -ArgumentList (,$list) } function Rebind-TaskView { param([Windows.Window]$Window) $grid = Get-TasksGrid -Window $Window if (-not $grid) { return } # 元データを取得(キャッシュ優先) $src = $script:LastTaskList if (-not $src -and $grid.ItemsSource) { $src = Ensure-Enumerable $grid.ItemsSource } $p = Get-TaskFilterParams -Window $Window # 現在のステータス選択を保持 $script:CurrentStatusFilter = if ($p.Status) { $p.Status } else { '(完了以外)' } $filtered = Build-FilteredTaskItems -Source (Ensure-Enumerable $src) ` -Field $p.Field ` -From $p.From ` -To $p.To ` -Kw $p.Kw ` -Status $script:CurrentStatusFilter $grid.ItemsSource = (To-ObservableCollection (Ensure-Enumerable $filtered)) # 件数表示 $num = Find-Ui -Window $Window -Name 'DailyTasksCountNum' if ($num -and $grid.ItemsSource) { $cnt = 0; foreach ($x in $grid.ItemsSource) { $cnt++ } $num.Text = [string]$cnt } } # ====== クリア(条件リセット & 既定=完了以外 で全件へ張り直し) ====== function Clear-TaskFilter { param([Windows.Window]$Window) $fromDP = Find-Ui -Window $Window -Name 'TaskFilterFrom'; if ($fromDP) { $fromDP.SelectedDate = $null } $toDP = Find-Ui -Window $Window -Name 'TaskFilterTo'; if ($toDP) { $toDP.SelectedDate = $null } $kwBox = Find-Ui -Window $Window -Name 'TaskFilterKeyword'; if ($kwBox) { $kwBox.Text = '' } $fieldSel = Find-Ui -Window $Window -Name 'TaskFilterField' if ($fieldSel -and $fieldSel.Items.Count -gt 0) { $fieldSel.SelectedIndex = 0 } # ステータスは既定に戻す $script:CurrentStatusFilter = '(完了以外)' $statusCmb = Find-Ui -Window $Window -Name 'TaskStatusFilter' if ($statusCmb) { # ItemsSource が無ければ設定 if (-not $statusCmb.ItemsSource -and $script:StatusChoices) { $statusCmb.ItemsSource = $script:StatusChoices } $statusCmb.SelectedItem = $script:CurrentStatusFilter } Set-DefaultCalendarToThisMonth -Window $Window $script:CurrentStatusFilter = '(完了以外)' # 全件に張り直し(ただし既定は「完了以外」なので Rebind で反映) Rebind-TaskView -Window $Window } # ====== 既存のハンドラ付け替え(Apply/Clear/Keyword/Status変化で張り直し) ====== function Attach-TaskFilterHandlers { param([Windows.Window]$Window) $applyBtn = Find-Ui -Window $Window -Name 'TaskFilterApply' if ($applyBtn) { $applyBtn.Add_Click({ Rebind-TaskView -Window $Window }) } $clearBtn = Find-Ui -Window $Window -Name 'TaskFilterClear' if ($clearBtn) { $clearBtn.Add_Click({ Clear-TaskFilter -Window $Window }) } $kwBox = Find-Ui -Window $Window -Name 'TaskFilterKeyword' if ($kwBox) { $kwBox.Add_TextChanged({ Rebind-TaskView -Window $Window }) } # ステータスコンボ:候補差し込み&選択変更で即反映 $statusCmb = Find-Ui -Window $Window -Name 'TaskStatusFilter' if ($statusCmb) { if ($script:StatusChoices -eq $null -and $script:LastTaskList) { $script:StatusChoices = Build-StatusChoicesFrom -source $script:LastTaskList } if ($script:StatusChoices) { $statusCmb.ItemsSource = $script:StatusChoices } if (-not $script:CurrentStatusFilter) { $script:CurrentStatusFilter = '(完了以外)' } $statusCmb.SelectedItem = $script:CurrentStatusFilter $statusCmb.Add_SelectionChanged({ # 現在選択を保持しつつ再バインド $sel = $null if ($statusCmb.SelectedItem) { $sel = [string]$statusCmb.SelectedItem } if (-not [string]::IsNullOrWhiteSpace($sel)) { $script:CurrentStatusFilter = $sel } Rebind-TaskView -Window $Window }) } } # ---------------- ダッシュボード用:データ提供&集計 ---------------- # 既存スキーマ(name/taskStatus/InqueryDate/DueDate)→ ダッシュボード集計スキーマ(Title/Status/Created/Updated/Due)にアダプト function Convert-ToDashboardTaskShape { param([object]$Row) if ($null -eq $Row) { return $null } $title = if ($Row.PSObject.Properties['name']) { [string]$Row.name } else { [string]$Row.Title } $status = if ($Row.PSObject.Properties['taskStatus']) { [string]$Row.taskStatus } else { [string]$Row.Status } $created = $null if ($Row.PSObject.Properties['InqueryDate']) { $created = Convert-ToDateOrNull $Row.InqueryDate } elseif ($Row.PSObject.Properties['Created']) { $created = Convert-ToDateOrNull $Row.Created } $updated = $null if ($Row.PSObject.Properties['Updated']) { $updated = Convert-ToDateOrNull $Row.Updated } elseif ($Row.PSObject.Properties['InqueryDate']) { $updated = Convert-ToDateOrNull $Row.InqueryDate } $due = $null if ($Row.PSObject.Properties['DueDate']) { $due = Convert-ToDateOrNull $Row.DueDate } elseif ($Row.PSObject.Properties['Due']) { $due = Convert-ToDateOrNull $Row.Due } return [pscustomobject]@{ Title = $title Status = $status Created = $created Updated = $updated Due = $due } } # ダッシュボードが呼ぶ想定のデータ取得API function Get-TaskData { param( [string]$TaskJsonPath # 省略可:省略時は直近 Initialize-TaskView / Update-TaskView のキャッシュを使用 ) # 1) キャッシュ優先 if (-not $TaskJsonPath -and $script:LastTaskList) { $arr = @() foreach ($r in $script:LastTaskList) { $x = Convert-ToDashboardTaskShape $r if ($x) { $arr += $x } } return ,$arr } # 2) パスが来た場合はロードして変換 if (-not $TaskJsonPath -and $script:LastTaskJsonPath) { $TaskJsonPath = $script:LastTaskJsonPath } if ($TaskJsonPath) { $doc = Read-TaskJson -Path $TaskJsonPath $list = Convert-Tasks -doc $doc $arr = @() foreach ($r in $list) { $x = Convert-ToDashboardTaskShape $r if ($x) { $arr += $x } } return ,$arr } return @() } # 集計(今日/前日・今月/先月・分布・リスク・トレンド) function Get-TaskStats { param([Parameter()][object[]]$Tasks) if (-not $Tasks -or $Tasks.Count -eq 0) { $Tasks = Get-TaskData } $today = (Get-Date).Date $yesterday = $today.AddDays(-1) $thisMonthStart = Get-Date -Year $today.Year -Month $today.Month -Day 1 $lastMonthStart = $thisMonthStart.AddMonths(-1) $lastMonthEnd = $thisMonthStart.AddDays(-1).Date function InRange($d,$s,$e) { if ($d -eq $null) { return $false }; return ($d -ge $s) -and ($d -le $e) } $dayToday = @($Tasks | Where-Object { InRange (Convert-ToDateOrNull $_.Created) $today $today }).Count $dayYesterday = @($Tasks | Where-Object { InRange (Convert-ToDateOrNull $_.Created) $yesterday $yesterday }).Count $monthThis = @($Tasks | Where-Object { InRange (Convert-ToDateOrNull $_.Created) $thisMonthStart $today }).Count $monthLast = @($Tasks | Where-Object { InRange (Convert-ToDateOrNull $_.Created) $lastMonthStart $lastMonthEnd }).Count function DiffPct($curr,$prev) { if ($prev -le 0) { if ($curr -gt 0) { return 100 } else { return 0 } } $v = (($curr - $prev) / [double]$prev) * 100 return [math]::Round($v) } $dod = DiffPct $dayToday $dayYesterday $mom = DiffPct $monthThis $monthLast $overdue = @($Tasks | Where-Object { $due = Convert-ToDateOrNull $_.Due ($due -ne $null) -and ($due -lt $today) -and ($_.Status -ne 'Done') }).Count $dueSoon = @($Tasks | Where-Object { $due = Convert-ToDateOrNull $_.Due ($due -ne $null) -and ($due -ge $today) -and ($due -le $today.AddDays(2)) -and ($_.Status -ne 'Done') }).Count $done = @($Tasks | Where-Object { $_.Status -eq 'Done' }).Count $total = $Tasks.Count $completion = if ($total -gt 0) { [math]::Round(100.0 * $done / $total, 1) } else { 0.0 } $leadTimes = @( $Tasks | Where-Object { $_.Status -eq 'Done' } | ForEach-Object { $c = Convert-ToDateOrNull $_.Created $u = Convert-ToDateOrNull $_.Updated if ($c -and $u -and $u -ge $c) { ($u - $c).TotalDays } } ) $avgLeadTime = if ($leadTimes.Count -gt 0) { [math]::Round(($leadTimes | Measure-Object -Average).Average, 1) } else { 0.0 } $byStatus = @() ($Tasks | Group-Object Status | Sort-Object Name) | ForEach-Object { $byStatus += [pscustomobject]@{ Status=$_.Name; Count=$_.Count } } $trend = @() 13..0 | ForEach-Object { $d = $today.AddDays(-$_) $cnt = @($Tasks | Where-Object { InRange (Convert-ToDateOrNull $_.Created) $d $d }).Count $trend += [pscustomobject]@{ Date=$d; Count=$cnt } } [pscustomobject]@{ Today = $dayToday Yesterday = $dayYesterday DayDeltaPct = $dod MonthThis = $monthThis MonthLast = $monthLast MonthDeltaPct = $mom Overdue = $overdue DueSoon = $dueSoon Completion = $completion AvgLeadDays = $avgLeadTime ByStatus = $byStatus Trend = $trend } } function Update-DashboardView { [CmdletBinding()] param( [Parameter(Mandatory)][Windows.Window]$Window, [Parameter(Mandatory)][System.Windows.FrameworkElement]$View # 例: タブ内の Grid(x:Name="DashboardView") ) # 1) データ取得&集計 $tasks = Get-TaskData $stats = Get-TaskStats -Tasks $tasks # 2) 名前付き要素を取る(View優先 → 見つからなければWindowから) function _find([string]$name) { $el = $View.FindName($name) if (-not $el) { $el = $Window.FindName($name) } return $el } $TxtToday = _find 'TxtToday' $TxtDayDelta = _find 'TxtDayDelta' $TxtDayDeltaAbs = _find 'TxtDayDeltaAbs' $TxtMonth = _find 'TxtMonth' $TxtMonthDelta = _find 'TxtMonthDelta' $TxtMonthDeltaAbs = _find 'TxtMonthDeltaAbs' $CanvasTrend = _find 'CanvasTrend' $PanelStatusBars = _find 'PanelStatusBars' $TxtOverdue = _find 'TxtOverdue' $TxtDueSoon = _find 'TxtDueSoon' $TxtCompletion = _find 'TxtCompletion' $TxtAvgLeadTime = _find 'TxtAvgLeadTime' # 3) KPI テキスト if ($TxtToday) { $TxtToday.Text = [string]$stats.Today } if ($TxtDayDelta) { $TxtDayDelta.Text = ('{0:+#;-#;0}%' -f $stats.DayDeltaPct) } if ($TxtDayDeltaAbs) { $TxtDayDeltaAbs.Text = ('{0:+#;-#;0}%' -f $stats.DayDeltaPct) } if ($TxtMonth) { $TxtMonth.Text = [string]$stats.MonthThis } if ($TxtMonthDelta) { $TxtMonthDelta.Text = ('{0:+#;-#;0}%' -f $stats.MonthDeltaPct) } if ($TxtMonthDeltaAbs) { $TxtMonthDeltaAbs.Text = ('{0:+#;-#;0}%' -f $stats.MonthDeltaPct) } # 増減の色 function Set-DeltaColor($tb, $value) { if (-not $tb) { return } if ($value -gt 0) { $tb.Foreground = 'LightGreen' } elseif ($value -lt 0) { $tb.Foreground = 'Tomato' } else { $tb.Foreground = 'Gainsboro' } } Set-DeltaColor $TxtDayDelta $stats.DayDeltaPct Set-DeltaColor $TxtDayDeltaAbs $stats.DayDeltaPct Set-DeltaColor $TxtMonthDelta $stats.MonthDeltaPct Set-DeltaColor $TxtMonthDeltaAbs $stats.MonthDeltaPct # リスク系 if ($TxtOverdue) { $TxtOverdue.Text = [string]$stats.Overdue } if ($TxtDueSoon) { $TxtDueSoon.Text = [string]$stats.DueSoon } if ($TxtCompletion) { $TxtCompletion.Text = ('{0:N1}%' -f $stats.Completion) } if ($TxtAvgLeadTime) { $TxtAvgLeadTime.Text= ('{0:N1}d' -f $stats.AvgLeadDays) } # 4) トレンド棒グラフ(Canvas) if ($CanvasTrend) { $CanvasTrend.Children.Clear() # キャンバス基本 $w = [double]$CanvasTrend.ActualWidth; if ($w -le 0) { $w = 760 } $h = [double]$CanvasTrend.ActualHeight; if ($h -le 0) { $h = 150 } # 余白(軸ラベル用) $padL = 40; $padR = 12; $padT = 12; $padB = 28 $plotW = [math]::Max(1, $w - $padL - $padR) $plotH = [math]::Max(1, $h - $padT - $padB) $n = 0 if ($stats.Trend) { $n = [int]$stats.Trend.Count } if ($n -le 0) { return } # スケール最大 $max = 0 foreach ($t in $stats.Trend) { if ($t.Count -gt $max) { $max = [int]$t.Count } } if ($max -lt 1) { $max = 1 } # ブラシ function NewBrush([string]$hex) { New-Object System.Windows.Media.SolidColorBrush ( [System.Windows.Media.Color]::FromRgb( [Convert]::ToInt32($hex.Substring(1,2),16), [Convert]::ToInt32($hex.Substring(3,2),16), [Convert]::ToInt32($hex.Substring(5,2),16) ) ) } $brushAxis = NewBrush '#2A2D33' $brushGrid = NewBrush '#23262C' $brushTick = NewBrush '#9097A5' $brushBar = NewBrush '#6EA9FF' # 平日 $brushWE = NewBrush '#8ACDFF' # 土日 $brushToday = NewBrush '#7EE081' # 今日 $brushAvg = NewBrush '#FFCC66' # 平均線 # --- グリッド & 目盛 --- 1..4 | ForEach-Object { $y = $padT + $plotH * (1.0 - ($_/4.0)) $ln = New-Object System.Windows.Shapes.Line $ln.X1 = $padL; $ln.X2 = $padL + $plotW $ln.Y1 = $y; $ln.Y2 = $y $ln.Stroke = $brushGrid; $ln.StrokeThickness = 1; $ln.StrokeDashArray = '2,2' [void]$CanvasTrend.Children.Add($ln) $lbl = New-Object System.Windows.Controls.TextBlock $lbl.Text = [string][math]::Round($max*($_/4.0)) $lbl.Foreground = $brushTick; $lbl.FontSize = 10 [System.Windows.Controls.Canvas]::SetLeft($lbl, 2) [System.Windows.Controls.Canvas]::SetTop($lbl, $y - 8) [void]$CanvasTrend.Children.Add($lbl) } # X/Y 軸 foreach ($seg in @( @{x1=$padL; y1=$padT; x2=$padL; y2=$padT+$plotH}, # Y軸 @{x1=$padL; y1=$padT+$plotH; x2=$padL+$plotW; y2=$padT+$plotH} # X軸 )) { $ax = New-Object System.Windows.Shapes.Line $ax.X1=$seg.x1; $ax.Y1=$seg.y1; $ax.X2=$seg.x2; $ax.Y2=$seg.y2 $ax.Stroke=$brushAxis; $ax.StrokeThickness=1.2 [void]$CanvasTrend.Children.Add($ax) } # 平均線 $avg = 0.0 if ($stats.Trend -and $stats.Trend.Count -gt 0) { $m = ($stats.Trend | Measure-Object -Property Count -Average) if ($m -and $m.Average -ne $null) { $avg = [double]$m.Average } } $yAvg = $padT + $plotH * (1.0 - ($avg / [double]$max)) $avgLn = New-Object System.Windows.Shapes.Line $avgLn.X1=$padL; $avgLn.X2=$padL+$plotW; $avgLn.Y1=$yAvg; $avgLn.Y2=$yAvg $avgLn.Stroke=$brushAvg; $avgLn.StrokeThickness=1; $avgLn.StrokeDashArray='4,3' [void]$CanvasTrend.Children.Add($avgLn) # --- 棒 --- $gap = [math]::Max(2, $plotW / $n * 0.35) $barW = [math]::Max(6, $plotW / $n - $gap) for ($i=0; $i -lt $n; $i++) { $data = $stats.Trend[$i] $d = [datetime]$data.Date $c = [double]$data.Count $x = $padL + $i * ($barW + $gap) + ($gap/2) $hBar = $plotH * ($c / [double]$max) $y = $padT + ($plotH - $hBar) $rect = New-Object System.Windows.Shapes.Rectangle $rect.Width = $barW $rect.Height = [math]::Max(1,$hBar) if ($d.Date -eq (Get-Date).Date) { $rect.Fill = $brushToday } elseif ($d.DayOfWeek -eq [DayOfWeek]::Saturday -or $d.DayOfWeek -eq [DayOfWeek]::Sunday) { $rect.Fill = $brushWE } else { $rect.Fill = $brushBar } $tt = New-Object System.Windows.Controls.ToolTip $tt.Content = ("{0:MM/dd} : {1} 件" -f $d,$c) $rect.ToolTip = $tt [System.Windows.Controls.Canvas]::SetLeft($rect, $x) [System.Windows.Controls.Canvas]::SetTop( $rect, $y) [void]$CanvasTrend.Children.Add($rect) if ( ($i % 2) -eq 0 ) { $lbl = New-Object System.Windows.Controls.TextBlock $lbl.Text = $d.ToString('MM/dd') $lbl.Foreground = $brushTick; $lbl.FontSize = 10 [System.Windows.Controls.Canvas]::SetLeft($lbl, $x - 6) [System.Windows.Controls.Canvas]::SetTop( $lbl, $padT+$plotH+4) [void]$CanvasTrend.Children.Add($lbl) } } } # 5) ステータス分布(ProgressBar 羅列) if ($PanelStatusBars) { $PanelStatusBars.Children.Clear() $total = 0 foreach ($row in $stats.ByStatus) { $total += $row.Count } if ($total -lt 1) { $total = 1 } foreach ($row in $stats.ByStatus) { $wrap = New-Object System.Windows.Controls.StackPanel $wrap.Orientation = 'Horizontal' $wrap.Margin = '0,2,0,2' $bar = New-Object System.Windows.Controls.ProgressBar $bar.Minimum = 0; $bar.Maximum = 100 $bar.Width = 260; $bar.Height = 16 $bar.Value = [math]::Round(100.0 * $row.Count / $total, 1) $lbl = New-Object System.Windows.Controls.TextBlock $lbl.Text = (" {0} {1}件" -f $row.Status, $row.Count) $lbl.Foreground = 'Gainsboro' $lbl.VerticalAlignment = 'Center' [void]$wrap.Children.Add($bar) [void]$wrap.Children.Add($lbl) [void]$PanelStatusBars.Children.Add($wrap) } } } function Convert-ToObservable { param([object]$value) $oc = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]' if ($null -eq $value) { return $oc } $isEnumerable = $value -is [System.Collections.IEnumerable] -and -not ($value -is [string]) if ($isEnumerable) { foreach ($i in $value) { [void]$oc.Add($i) } } else { [void]$oc.Add($value) } return $oc } function Set-DefaultCalendarToThisMonth { param([Windows.Window]$Window) $fromDP = Find-Ui -Window $Window -Name 'TaskFilterFrom' $toDP = Find-Ui -Window $Window -Name 'TaskFilterTo' if (-not $fromDP -and -not $toDP) { return } $today = (Get-Date).Date $first = Get-Date -Year $today.Year -Month $today.Month -Day 1 $last = $first.AddMonths(1).AddDays(-1) if ($fromDP) { $fromDP.SelectedDate = $first if ($fromDP.PSObject.Properties['DisplayDateStart']) { $fromDP.DisplayDateStart = $first.AddMonths(-6) } if ($fromDP.PSObject.Properties['DisplayDateEnd']) { $fromDP.DisplayDateEnd = $last.AddMonths(6) } } if ($toDP) { $toDP.SelectedDate = $last if ($toDP.PSObject.Properties['DisplayDateStart']) { $toDP.DisplayDateStart = $first.AddMonths(-6) } if ($toDP.PSObject.Properties['DisplayDateEnd']) { $toDP.DisplayDateEnd = $last.AddMonths(6) } } } # ---------------- エクスポート ---------------- Export-ModuleMember -Function ` Initialize-TaskView, Update-TaskView, Attach-TaskFilterHandlers, Get-TaskFilterParams, Clear-TaskFilter, ` Get-TaskData, Get-TaskStats, Update-DashboardView
function Apply-GridColumnsFromJson { param( [Parameter(Mandatory)][Windows.Window]$Window, [Parameter(Mandatory)]$Doc, [switch]$PreferColumnHeaders ) $grid = Get-TasksGrid -Window $Window if (-not $grid) { return } if ($grid.Columns) { $grid.Columns.Clear() } $map = Get-ColumnMap -Doc $Doc -PreferColumnHeaders:$PreferColumnHeaders $widthByKey = @{} if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { foreach ($c in $Doc.COLUMN_HEADERS) { if ($c.key) { $widthByKey[[string]$c.key] = $c.width } } } foreach ($jsonKey in $map.Keys) { $prop = $map[$jsonKey] $width = $widthByKey[$jsonKey] # ★ name列は外部テンプレートを使う if ($prop -eq 'name') { $tmpl = Get-TemplateByKey -Key 'TaskGridCellTemplate_Name' if ($tmpl -and ($tmpl -is [System.Windows.DataTemplate])) { $tmplCol = New-Object System.Windows.Controls.DataGridTemplateColumn # COLUMN_HEADERS に header があればそれを、無ければ jsonKey を表示名に $headerText = $jsonKey if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { $found = $Doc.COLUMN_HEADERS | Where-Object { $_.key -eq $jsonKey } | Select-Object -First 1 if ($found -and $found.header) { $headerText = [string]$found.header } } $tmplCol.Header = $headerText $tmplCol.CellTemplate = $tmpl if ($width) { $tmplCol.Width = $width } else { $tmplCol.Width = [System.Windows.Controls.DataGridLength]::Auto } [void]$grid.Columns.Add($tmplCol) continue } # テンプレートが無い場合のフォールバック(TextColumn) $col = New-Object System.Windows.Controls.DataGridTextColumn $col.Header = $jsonKey $col.Binding = New-Object System.Windows.Data.Binding($prop) $col.Width = if ($width) { $width } else { [System.Windows.Controls.DataGridLength]::SizeToHeader } [void]$grid.Columns.Add($col) continue } # それ以外はテキスト列(既定) $col = New-Object System.Windows.Controls.DataGridTextColumn # COLUMN_HEADERS の header を優先 $headerText = $jsonKey if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { $found = $Doc.COLUMN_HEADERS | Where-Object { $_.key -eq $jsonKey } | Select-Object -First 1 if ($found -and $found.header) { $headerText = [string]$found.header } } $col.Header = $headerText $col.Binding = New-Object System.Windows.Data.Binding($prop) if ($width) { $col.Width = $width } else { switch ($prop) { 'id' { $col.Width = 80 } 'inqDisplay' { $col.Width = 150 } 'dueDisplay' { $col.Width = 150 } 'taskStatus' { $col.Width = 120 } default { $col.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } } } [void]$grid.Columns.Add($col) } }
<!-- xaml/GridTemplates.xaml --> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <!-- タスク名セル(ダブルクリックで URL を開く) --> <DataTemplate x:Key="TaskGridCellTemplate_Name"> <TextBlock Text="{Binding name}" Foreground="#3794FF" TextDecorations="Underline" Cursor="Hand"> <TextBlock.InputBindings> <MouseBinding MouseAction="LeftDoubleClick" Command="{x:Static NavigationCommands.GoToPage}" CommandParameter="{Binding url}" /> </TextBlock.InputBindings> </TextBlock> </DataTemplate> </ResourceDictionary>
function Apply-GridColumnsFromJson { param( [Parameter(Mandatory)][Windows.Window]$Window, [Parameter(Mandatory)]$Doc, [switch]$PreferColumnHeaders ) $grid = Get-TasksGrid -Window $Window if (-not $grid) { return } if ($grid.Columns) { $grid.Columns.Clear() } $map = Get-ColumnMap -Doc $Doc -PreferColumnHeaders:$PreferColumnHeaders $widthByKey = @{} if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { foreach ($c in $Doc.COLUMN_HEADERS) { if ($c.key) { $widthByKey[[string]$c.key] = $c.width } } } foreach ($jsonKey in $map.Keys) { $prop = $map[$jsonKey] $width = $widthByKey[$jsonKey] # ★ name列は外部テンプレートを使う if ($prop -eq 'name') { $tmpl = Get-TemplateByKey -Key 'TaskGridCellTemplate_Name' if ($tmpl -and ($tmpl -is [System.Windows.DataTemplate])) { $tmplCol = New-Object System.Windows.Controls.DataGridTemplateColumn # COLUMN_HEADERS に header があればそれを、無ければ jsonKey を表示名に $headerText = $jsonKey if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { $found = $Doc.COLUMN_HEADERS | Where-Object { $_.key -eq $jsonKey } | Select-Object -First 1 if ($found -and $found.header) { $headerText = [string]$found.header } } $tmplCol.Header = $headerText $tmplCol.CellTemplate = $tmpl if ($width) { $tmplCol.Width = $width } else { $tmplCol.Width = [System.Windows.Controls.DataGridLength]::Auto } [void]$grid.Columns.Add($tmplCol) continue } # テンプレートが無い場合のフォールバック(TextColumn) $col = New-Object System.Windows.Controls.DataGridTextColumn $col.Header = $jsonKey $col.Binding = New-Object System.Windows.Data.Binding($prop) $col.Width = if ($width) { $width } else { [System.Windows.Controls.DataGridLength]::SizeToHeader } [void]$grid.Columns.Add($col) continue } # それ以外はテキスト列(既定) $col = New-Object System.Windows.Controls.DataGridTextColumn # COLUMN_HEADERS の header を優先 $headerText = $jsonKey if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { $found = $Doc.COLUMN_HEADERS | Where-Object { $_.key -eq $jsonKey } | Select-Object -First 1 if ($found -and $found.header) { $headerText = [string]$found.header } } $col.Header = $headerText $col.Binding = New-Object System.Windows.Data.Binding($prop) if ($width) { $col.Width = $width } else { switch ($prop) { 'id' { $col.Width = 80 } 'inqDisplay' { $col.Width = 150 } 'dueDisplay' { $col.Width = 150 } 'taskStatus' { $col.Width = 120 } default { $col.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } } } [void]$grid.Columns.Add($col) } }
Main.ps1
'TaskData.psm1'を追加する。
$modules = Join-Path $ScriptDir 'modules' $moduleFiles = @( 'UiHelpers.psm1', 'JsonData.psm1', 'Navigation.psm1', 'ServiceMonitor.psm1', 'AppTimers.psm1', 'AppLauncher.psm1', 'AppIcons.psm1', 'TaskData.psm1' )
function Try-Load-And-Content { param( [string]$FileName, [System.Windows.Controls.ContentControl]$TargetHost ) if (-not $TargetHost) { return $null } $path = Join-Path $ScriptDir $FileName if (-not (Test-Path $path)) { return $null } $ctrl = Load-Xaml $path if ($ctrl) { $TargetHost.Content = $ctrl } return $ctrl } $tabTaskTrend = Try-Load-And-Content 'Tab.TaskTrend.xaml' -TargetHost $TabTaskTrendHost
TaskData.psm1でExport-ModuleMemberで設定した関数を追加する。
# 依存関数の存在チェック(ここで止まったらモジュール内Export名の問題) $required = @( 'Load-Xaml', 'Set-AppDataPath','Get-AppData','Reload-AppData', 'Bind-DataContext', 'Register-HyperlinkHandler', 'Initialize-ServiceMonitor','Attach-ServiceButtons', 'Resolve-PidFile','Start-MyService','Stop-MyService','Test-ServiceRunning','Update-ServiceStatusUI', 'New-UiTimer','Start-UiTimer','Stop-UiTimer', 'Initialize-TaskView', 'Update-TaskView', 'Attach-TaskFilterHandlers', 'Apply-TaskFilter', 'Clear-TaskFilter' ) fo Initialize-TaskView -Window $window -TaskJsonPath $taskJsonPath Attach-TaskFilterHandlers -Window $window
Tab.TaskTrend.xaml
taskviewのUIを作成する。ここでは、新しいタブを作成して描画するようにしている。
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="DashboardView" Background="#0F0F10"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <!-- KPI Row --> <Grid Grid.Row="0" Margin="16"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Border Grid.Column="0" CornerRadius="10" Background="#1E1F25" Padding="12" Margin="6"> <StackPanel> <TextBlock Text="本日のタスク件数" Foreground="#BBB" FontSize="12"/> <TextBlock x:Name="TxtToday" Text="0" Foreground="White" FontSize="28" FontWeight="Bold"/> <TextBlock x:Name="TxtDayDelta" Text="+0%" Foreground="#7EE081" FontSize="12"/> </StackPanel> </Border> <Border Grid.Column="1" CornerRadius="10" Background="#1E1F25" Padding="12" Margin="6"> <StackPanel> <TextBlock Text="前日比" Foreground="#BBB" FontSize="12"/> <TextBlock x:Name="TxtDayDeltaAbs" Text="+0%" Foreground="White" FontSize="28" FontWeight="Bold"/> <TextBlock x:Name="TxtDayDeltaHint" Text="vs 昨日" Foreground="#999" FontSize="12"/> </StackPanel> </Border> <Border Grid.Column="2" CornerRadius="10" Background="#1E1F25" Padding="12" Margin="6"> <StackPanel> <TextBlock Text="今月のタスク件数" Foreground="#BBB" FontSize="12"/> <TextBlock x:Name="TxtMonth" Text="0" Foreground="White" FontSize="28" FontWeight="Bold"/> <TextBlock x:Name="TxtMonthDelta" Text="+0%" Foreground="#7EE081" FontSize="12"/> </StackPanel> </Border> <Border Grid.Column="3" CornerRadius="10" Background="#1E1F25" Padding="12" Margin="6"> <StackPanel> <TextBlock Text="先月比" Foreground="#BBB" FontSize="12"/> <TextBlock x:Name="TxtMonthDeltaAbs" Text="+0%" Foreground="White" FontSize="28" FontWeight="Bold"/> <TextBlock x:Name="TxtMonthDeltaHint" Text="vs 先月" Foreground="#999" FontSize="12"/> </StackPanel> </Border> </Grid> <!-- 上段:トレンド + リスク --> <Grid Grid.Row="1" Margin="8,0,8,8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="2*"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Border Grid.Column="0" CornerRadius="10" Background="#15161B" Margin="8" Padding="12" SnapsToDevicePixels="True" UseLayoutRounding="True" ClipToBounds="True"> <Grid x:Name="TrendHost" ClipToBounds="True"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock Text="日次タスク推移(直近14日)" Foreground="White" FontWeight="SemiBold" /> <Canvas x:Name="CanvasTrend" Grid.Row="1" Margin="8" Background="#121319" Height="180" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" SnapsToDevicePixels="True" ClipToBounds="True"/> </Grid> </Border> <Border Grid.Column="1" CornerRadius="10" Background="#15161B" Margin="8" Padding="12"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock Text="リスク" Foreground="White" FontWeight="SemiBold" /> <Grid Grid.Row="1" Margin="4"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <Border Grid.Row="0" Grid.Column="0" Background="#101114" CornerRadius="8" Padding="10" Margin="4" BorderBrush="#2A2D33" BorderThickness="1"> <DockPanel> <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="18" Foreground="#FF6B6B" Margin="0,0,8,0"/> <StackPanel> <TextBlock Text="期限切れ" Foreground="#9AA0A6" FontSize="12"/> <TextBlock x:Name="TxtOverdue" Text="0" Foreground="White" FontSize="24" FontWeight="Bold"/> </StackPanel> </DockPanel> </Border> <Border Grid.Row="0" Grid.Column="1" Background="#101114" CornerRadius="8" Padding="10" Margin="4" BorderBrush="#2A2D33" BorderThickness="1"> <DockPanel> <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="18" Foreground="#F7D794" Margin="0,0,8,0"/> <StackPanel> <TextBlock Text="期限2日以内" Foreground="#9AA0A6" FontSize="12"/> <TextBlock x:Name="TxtDueSoon" Text="0" Foreground="White" FontSize="24" FontWeight="Bold"/> </StackPanel> </DockPanel> </Border> <Border Grid.Row="1" Grid.Column="0" Background="#101114" CornerRadius="8" Padding="10" Margin="4" BorderBrush="#2A2D33" BorderThickness="1"> <StackPanel> <TextBlock Text="完了率" Foreground="#9AA0A6" FontSize="12"/> <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,4,0,0"> <ProgressBar x:Name="PbCompletion" Width="160" Height="12" Minimum="0" Maximum="100"/> <TextBlock x:Name="TxtCompletion" Text="0%" Foreground="White" Margin="8,0,0,0" FontWeight="Bold"/> </StackPanel> </StackPanel> </Border> <Border Grid.Row="1" Grid.Column="1" Background="#101114" CornerRadius="8" Padding="10" Margin="4" BorderBrush="#2A2D33" BorderThickness="1"> <DockPanel> <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="18" Foreground="#7EA7FF" Margin="0,0,8,0"/> <StackPanel> <TextBlock Text="平均処理日数" Foreground="#9AA0A6" FontSize="12"/> <TextBlock x:Name="TxtAvgLeadTime" Text="0.0d" Foreground="White" FontSize="24" FontWeight="Bold"/> </StackPanel> </DockPanel> </Border> </Grid> </Grid> </Border> </Grid> <!-- 下段:フィルタ + DataGrid --> <Border Grid.Row="2" Margin="16" Background="#15161B" CornerRadius="10" Padding="12"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <DockPanel Grid.Row="0" LastChildFill="False" Margin="0,0,0,8"> <TextBlock Text="Filter:" Foreground="#bbb" VerticalAlignment="Center" Margin="0,0,8,0"/> <ComboBox x:Name="TaskFilterField" Width="140" Margin="0,0,8,0"> <ComboBoxItem Content="inquery_date" IsSelected="True"/> <ComboBoxItem Content="due_date"/> </ComboBox> <DatePicker x:Name="TaskFilterFrom" Width="160" Margin="0,0,8,0"/> <TextBlock Text="~" Foreground="#bbb" VerticalAlignment="Center" Margin="0,0,8,0"/> <DatePicker x:Name="TaskFilterTo" Width="160" Margin="0,0,8,0"/> <TextBox x:Name="TaskFilterKeyword" Width="160" Margin="8,0,8,0" Foreground="White" Background="#1E1F25" BorderBrush="#2A2D33" /> <Button x:Name="TaskFilterApply" Content="適用" Margin="0,0,6,0" Padding="10,4" Background="#0E639C" Foreground="White" BorderBrush="#0E639C"/> <Button x:Name="TaskFilterClear" Content="解除" Padding="10,4" Background="#3A3D41" Foreground="White" BorderBrush="#3A3D41"/> <TextBlock Text="ステータス:" VerticalAlignment="Center" Foreground="White" Margin="8,0,6,0"/> <ComboBox x:Name="TaskStatusFilter" Width="220" IsEditable="False" SelectedIndex="0" ToolTip="(完了以外)/(すべて)/JSONから自動抽出されたステータス"/> </DockPanel> <DataGrid x:Name="DailyTasksGrid" Grid.Row="1" AutoGenerateColumns="False" HeadersVisibility="Column" CanUserAddRows="False" CanUserDeleteRows="False" Background="#15161B" Foreground="White" GridLinesVisibility="Horizontal" BorderBrush="#2A2D33" BorderThickness="1" ScrollViewer.CanContentScroll="True" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto" RowBackground="#1E1F25" AlternatingRowBackground="#20242A" HorizontalGridLinesBrush="#2A2D33" VerticalGridLinesBrush="#2A2D33" ColumnHeaderHeight="28"> <DataGrid.ColumnHeaderStyle> <Style TargetType="DataGridColumnHeader"> <Setter Property="Background" Value="#1A1D22"/> <Setter Property="Foreground" Value="#C2C7CF"/> <Setter Property="BorderBrush" Value="#2A2D33"/> <Setter Property="BorderThickness" Value="0,0,0,1"/> <Setter Property="FontWeight" Value="SemiBold"/> </Style> </DataGrid.ColumnHeaderStyle> <DataGrid.CellStyle> <Style TargetType="DataGridCell"> <Setter Property="Background" Value="Transparent"/> <Setter Property="BorderBrush" Value="#2A2D33"/> <Setter Property="BorderThickness" Value="0,0,0,1"/> <Style.Triggers> <Trigger Property="IsSelected" Value="True"> <Setter Property="Background" Value="#223047"/> <Setter Property="Foreground" Value="White"/> </Trigger> </Style.Triggers> </Style> </DataGrid.CellStyle> <DataGrid.Columns> <DataGridTextColumn Header="TASK ID" Binding="{Binding id}" Width="80"/> <DataGridTemplateColumn Header="TASK NAME" Width="2*"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding name}" Foreground="#3794FF" TextDecorations="Underline" Cursor="Hand"> <TextBlock.InputBindings> <MouseBinding MouseAction="LeftDoubleClick" Command="{x:Static NavigationCommands.GoToPage}" CommandParameter="{Binding url}"/> </TextBlock.InputBindings> </TextBlock> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn> <DataGridTextColumn Header="INQUERY DATE" Binding="{Binding inqDisplay}" Width="150"/> <DataGridTextColumn Header="DUE DATE" Binding="{Binding dueDisplay}" Width="150"/> <DataGridTextColumn Header="STATUS" Binding="{Binding taskStatus}" Width="120"/> </DataGrid.Columns> </DataGrid> </Grid> </Border> </Grid>
function Get-ColumnMap { <# 返り値: [ordered] hashtable Key = JSONのキー(ヘッダー名として使う) Value = 表示用オブジェクトのプロパティ名(Convert-Tasks で作ったやつ) 例: task_id -> id task_name -> name inquery_date -> inqDisplay due_date -> dueDisplay status -> taskStatus task_URL -> url #> param( [Parameter(Mandatory)]$Doc, # ConvertFrom-Json の結果($doc) [Parameter()][switch]$PreferColumnHeaders # $doc.COLUMN_HEADERS を優先するか ) # 既知のマップ(JSONキー → 表示用プロパティ) $known = @{ 'task_id' = 'id' 'task_name' = 'name' 'task_URL' = 'url' 'inquery_date' = 'inqDisplay' 'due_date' = 'dueDisplay' 'status' = 'taskStatus' } # 1) 優先: 明示の列定義(任意) # 形式例: # "COLUMN_HEADERS": [ # {"key":"task_id","header":"TASK ID","width":80}, # {"key":"task_name","header":"TASK NAME","width":"2*"}, # {"key":"inquery_date","header":"INQUERY DATE","width":150}, # {"key":"due_date","header":"DUE DATE","width":150}, # {"key":"status","header":"STATUS","width":120} # ] if ($PreferColumnHeaders -and $Doc -and $Doc.PSObject.Properties['COLUMN_HEADERS']) { $ordered = [ordered]@{} foreach ($col in $Doc.COLUMN_HEADERS) { $k = [string]$col.key if ([string]::IsNullOrWhiteSpace($k)) { continue } $prop = $known[$k] if (-not $prop) { $prop = $k } # 未知キーはそのまま $ordered[$k] = $prop } if ($ordered.Count -gt 0) { return $ordered } } # 2) 自動: 最初の行のキー順で作る $ordered2 = [ordered]@{} $first = $null if ($Doc -and $Doc.TASK_DATA -and $Doc.TASK_DATA.Count -gt 0) { $first = $Doc.TASK_DATA[0] } if ($first) { foreach ($p in $first.PSObject.Properties) { $jsonKey = [string]$p.Name $prop = $known[$jsonKey] if (-not $prop) { # Convert-Tasks で変換してないフィールドは、同名でアクセスできないのでスキップ # もし表示したければ Convert-Tasks にプロパティ追加してここでマップする continue } if (-not $ordered2.Contains($jsonKey)) { $ordered2[$jsonKey] = $prop } } } # 3) 予備: 既知セットのうち存在しそうなものを補完 foreach ($k in @('task_id','task_name','inquery_date','due_date','status')) { if (-not $ordered2.Contains($k) -and $known.ContainsKey($k)) { $ordered2[$k] = $known[$k] } } return $ordered2 } function Apply-GridColumnsFromJson { param( [Parameter(Mandatory)][Windows.Window]$Window, [Parameter(Mandatory)]$Doc, # ConvertFrom-Json の結果 [switch]$PreferColumnHeaders ) $grid = Get-TasksGrid -Window $Window if (-not $grid) { return } # 列をクリア if ($grid.Columns) { $grid.Columns.Clear() } # マップ取得(キー=ヘッダー表記、値=Binding先プロパティ) $map = Get-ColumnMap -Doc $Doc -PreferColumnHeaders:$PreferColumnHeaders # 幅指定が COLUMN_HEADERS に在るなら活用(なければ既定) $widthByKey = @{} if ($Doc.PSObject.Properties['COLUMN_HEADERS']) { foreach ($c in $Doc.COLUMN_HEADERS) { if ($c.key) { $widthByKey[[string]$c.key] = $c.width } } } foreach ($jsonKey in $map.Keys) { $prop = $map[$jsonKey] $width = $widthByKey[$jsonKey] # 名前列はURLダブルクリックできるテンプレート列に(既定) if ($prop -eq 'name') { $tmplCol = New-Object System.Windows.Controls.DataGridTemplateColumn $tmplCol.Header = $jsonKey # DataTemplateをXAMLで組み立て(PowerShell 5.x互換の素直なやり方) $xaml = @" <DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <TextBlock Text="{Binding name}" Foreground="#3794FF" TextDecorations="Underline" Cursor="Hand"> <TextBlock.InputBindings> <MouseBinding MouseAction="LeftDoubleClick" Command="{x:Static NavigationCommands.GoToPage}" CommandParameter="{Binding url}"/> </TextBlock.InputBindings> </TextBlock> </DataTemplate> "@ [xml]$xx = $xaml $reader = New-Object System.Xml.XmlNodeReader $xx $tmplCol.CellTemplate = [Windows.Markup.XamlReader]::Load($reader) if ($width) { $tmplCol.Width = $width } else { $tmplCol.Width = [System.Windows.Controls.DataGridLength]::Auto } [void]$grid.Columns.Add($tmplCol) continue } # それ以外はテキスト列 $col = New-Object System.Windows.Controls.DataGridTextColumn $col.Header = $jsonKey $col.Binding = New-Object System.Windows.Data.Binding($prop) if ($width) { $col.Width = $width } else { switch ($prop) { 'id' { $col.Width = 80 } 'inqDisplay' { $col.Width = 150 } 'dueDisplay' { $col.Width = 150 } 'taskStatus' { $col.Width = 120 } default { $col.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } } } [void]$grid.Columns.Add($col) } }
Initialize-TaskView
if ($grid) { $grid.ItemsSource = $list # ★ 追加:ヘッダー自動取り込み(COLUMN_HEADERS があれば優先) Apply-GridColumnsFromJson -Window $Window -Doc $doc -PreferColumnHeaders }
よくあるハマりどころ
- STA 必須:WPF/UI 操作は STA で。エントリースクリプトで STA 自動再起動を入れておくと安心。
- ICollectionView 化が大事:DataGrid の ItemsSource が配列などのままだと
Filter
が効かない。必ずEnsure-TasksCollectionView
を通す。 - 日付比較は
DateTime
で:文字列比較(-gt
/-lt
)は NG。$dt.Date -eq $now.Date
のように厳密に。 - 関数名の不一致:
Update-TaskView
内はEnsure-TasksCollectionView
を呼ぶ(本記事のコードは修正済み)。 - ハイパーリンクが開かない:
Hyperlink.RequestNavigate
をハンドラで拾いProcess.Start
などを呼ぶ。
拡張アイデア
- ステータス列で色分け(
status
に応じて RowStyle を切り替え) - ソート保持(
ICollectionView
のSortDescriptions
を保存/復元) - CSV/Excel エクスポート(ビューの内容のみ出力)
- 差分強調(本日追加の行にアクセント背景)
- ローカルファイル監視で JSON 変更を自動反映(
FileSystemWatcher
)
<# .SYNOPSIS ファイル内の \uXXXX 形式のUnicodeエスケープ文字をデコードし、Shift-JISで再保存する。 .DESCRIPTION PowerShell 5.x対応。 元ファイル内に含まれるユニコードエスケープ(\uXXXX)をすべて実際の文字に置換し、 SJISエンコードで出力ファイルに保存します。 .PARAMETER InputPath 読み込むファイルパス .PARAMETER OutputPath 出力先ファイルパス(省略時は「_sjis.txt」を付加して保存) .EXAMPLE PS> .\Decode-UnicodeToSJIS.ps1 -InputPath ".\input.txt" #> param( [Parameter(Mandatory = $true)] [string]$InputPath, [string]$OutputPath ) # --- 出力先設定 --- if (-not $OutputPath) { $dir = Split-Path $InputPath $name = [System.IO.Path]::GetFileNameWithoutExtension($InputPath) $ext = [System.IO.Path]::GetExtension($InputPath) $OutputPath = Join-Path $dir ("{0}_sjis{1}" -f $name, $ext) } # --- ファイルをUTF-8で読み込み --- $content = Get-Content -Raw -Encoding UTF8 -Path $InputPath # --- \uXXXX をUnicode文字に変換 --- $decoded = [System.Text.RegularExpressions.Regex]::Replace( $content, '\\u([0-9A-Fa-f]{4})', { param($m) [char]([int]("0x" + $m.Groups[1].Value)) } ) # --- Shift-JISに変換して保存 --- [System.IO.File]::WriteAllText( $OutputPath, $decoded, [System.Text.Encoding]::GetEncoding("shift_jis") ) Write-Host "✅ 変換完了: $OutputPath"