PowerShell + WPF で作る「JSONデータを読み込んでビューに表示」

下記記事で作成したGUIアプリの機能を拡張してみようと思います。

2025/10/4

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 など

2025/10/4

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 を切り替え)
  • ソート保持ICollectionViewSortDescriptions を保存/復元)
  • 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"

スポンサーリンク

-IT関連
-,