Newer
Older
client_dwatch / lib / ds-logs / query.ts
/**
 * 制御盤から受け取ったログの一覧の絞り込み・並べ替え・ページ分け(指示書AY 段階3-2。D8)。
 * 期待値の正は指示書AY §3 表1(`__tests__/query.test.ts`)。
 *
 * ★純粋関数★(`CLAUDE.md` §9.4)。一覧は制御盤からもらい、D-Watch はここで絞るだけ。
 * ★`modified_at` は `Date.parse()` の瞬間で比べる・並べる★(文字列で比べるとオフセット違いで順序が狂う。4-17)。
 * 入力の `modified_at` は検証済み(オフセット付き・存在する日付。`lib/ds-logs/validate.ts`)である前提。
 */
import { toJstIso } from "@/lib/format/date";
import type { DsLogFile, DsLogSort, DsLogType } from "@/lib/types";

export interface DeviceLogQuery {
  /** `null` = 全種類。★`types` に無いキーは0件★(D23) */
  types: readonly string[] | null;
  /** 瞬間(ms)。`from ≤ t` */
  from: number | null;
  /** 瞬間(ms)。`t < to` */
  to: number | null;
  sort: DsLogSort;
  /** 1〜 */
  page: number;
  perPage: number;
}

/** 名前の昇順。★`localeCompare` を使わない★(実行環境のロケールで順序を変えない) */
function compareName(a: string, b: string): number {
  return a < b ? -1 : a > b ? 1 : 0;
}

/**
 * 表1。★入力の配列を書き換えない★。
 * - `date_desc`: 新しい順 → 同時刻は name 昇順
 * - `type_date_desc`: 制御盤の `types` 配列の順(★クエリの順ではない★)→ 新しい順 → name 昇順
 * 出力の `modified_at` は `toJstIso()` で JST に揃える(表1 Q17。`CLAUDE.md` §9.3)。
 */
export function queryDeviceLogs(
  types: readonly DsLogType[],
  items: readonly DsLogFile[],
  query: DeviceLogQuery
): { items: DsLogFile[]; total: number } {
  const wanted = query.types === null ? null : new Set(query.types);
  const typeOrder = new Map(types.map((t, index) => [t.key, index]));

  const filtered = items
    .map((item) => ({ item, at: Date.parse(item.modified_at) }))
    .filter(({ item, at }) => {
      if (wanted !== null && !wanted.has(item.type)) return false;
      if (query.from !== null && at < query.from) return false;
      if (query.to !== null && at >= query.to) return false;
      return true;
    });

  filtered.sort((a, b) => {
    if (query.sort === "type_date_desc") {
      const byType =
        (typeOrder.get(a.item.type) ?? Number.MAX_SAFE_INTEGER) -
        (typeOrder.get(b.item.type) ?? Number.MAX_SAFE_INTEGER);
      if (byType !== 0) return byType;
    }
    if (a.at !== b.at) return b.at - a.at;
    return compareName(a.item.name, b.item.name);
  });

  const start = (query.page - 1) * query.perPage;
  const page = filtered.slice(start, start + query.perPage).map(({ item, at }) => ({
    ...item,
    modified_at: toJstIso(new Date(at)),
  }));

  return { items: page, total: filtered.length };
}

/**
 * モーダルの `types` クエリ(指示書BJ 表Q・J10)。
 * `null` = パラメータを付けない(省略=全種類、または種類未初期化・種類0件)。
 * ★全種類は「件数同じかつ known を全部含む」★(件数だけで誤認しない。罠5)。
 */
export function deviceLogTypesParam(
  knownTypeKeys: readonly string[],
  selected: ReadonlySet<string> | null
): string | null {
  if (selected === null) return null;
  if (knownTypeKeys.length === 0) return null;
  if (
    selected.size === knownTypeKeys.length &&
    knownTypeKeys.every((key) => selected.has(key))
  ) {
    return null;
  }
  const knownSelected = knownTypeKeys.filter((key) => selected.has(key));
  const unknown = [...selected].filter((key) => !knownTypeKeys.includes(key));
  return [...knownSelected, ...unknown].join(",");
}