下記記事で作成した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"
