SCALE — Build Lab
開発パターン · TYPESCRIPT LIBRARY

タスク自動登録

CATEGORY開発パターン TYPETypeScript Library EFFORT240〜600分 DIFFICULTY
PRIMARY CODE
ts · lib/auto-register-task.ts
// 手入力したタスク(朝日報・作業開始・作業終了 等の自由記述)を
// タスクシートに自動登録するヘルパー。
// 重複(同じ名前の自分担当・未完了タスク)がある場合は既存IDを返す。

import { isMineAssignee, type Task, type BottomTask } from './tasks-api';
import { getTodayDate, nowISO, genId } from './work-log';
import { normalizeName } from './normalize-name';

export interface AutoRegisterOptions {
  taskTexts: string[];                  // 登録したいタスク名の配列
  baseTasks: Task[];                    // 現在のタスクシート
  currentUserName: string;              // ログイン中ユーザー名
  alreadySelectedIds?: string[];        // 既に baseTaskIds として選択済みのタスクID
  alreadySelectedRoutineIds?: string[]; // 既に selectedRoutineIds として選択済みの定例タスクID
  defaultDue?: string;                  // 期日(未指定なら今日)
  memoPrefix?: string;                  // memo欄のプリフィックス(例: '朝日報で追加')
  origin?: string;                      // 由来: morning / active / end / manual / slack-bot 等
  // タスク名 → 推定時間(分) のマップ(カレンダー由来など、自動でセットしたい場合)
  // 既存タスクで estimateMinutes が未設定のものにも適用する
  estimateMinutesByName?: Map<string, number> | Record<string, number>;
  // 定例タスクと同名のテキスト入力は、新規通常タスクを作らず定例ID として返す
  // (routine と通常タスクの重複生成を防ぐ)
  bottomTasks?: BottomTask[];
}

export interface AutoRegisterResult {
  newTasks: Task[];          // 新規作成されたタスク
  mergedBaseTaskIds: string[]; // 自動登録後の baseTaskIds(既存ID + 新規ID)
  mergedRoutineIds: string[];  // 自動登録後の selectedRoutineIds(routine 同名で引き継いだIDも含む)
  estimateUpdatedIds: string[]; // 既存タスクで estimateMinutes が新たにセットされた ID(カレンダー由来など)
  skippedAsRoutine: string[];   // routine 同名でスキップされたタスク名(デバッグ用)
}

export function autoRegisterMemoTasks(opts: AutoRegisterOptions): AutoRegisterResult {
  const {
    taskTexts,
    baseTasks,
    currentUserName,
    alreadySelectedIds = [],
    alreadySelectedRoutineIds = [],
    defaultDue,
    memoPrefix = 'メモから自動登録',
    estimateMinutesByName,
    bottomTasks = [],
  } = opts;

  const today = defaultDue || getTodayDate();
  // 複数担当(assignees) 込みで判定
  const existingMineUnfinished = baseTasks.filter(t => isMineAssignee(t, currentUserName) && t.status !== '完了');
  const selectedBaseNames = new Set(baseTasks.filter(t => alreadySelectedIds.includes(t.id)).map(t => t.name));

  // estimateMinutesByName を統一的に扱うためのアクセサ
  const getEst = (name: string): number | undefined => {
    if (!estimateMinutesByName) return undefined;
    if (estimateMinutesByName instanceof Map) return estimateMinutesByName.get(name);
    return (estimateMinutesByName as Record<string, number>)[name];
  };

  const newTasks: Task[] = [];
  const mergedBaseTaskIds = [...alreadySelectedIds];
  const mergedRoutineIds = [...alreadySelectedRoutineIds];
  const estimateUpdatedIds: string[] = [];
  const skippedAsRoutine: string[] = [];

  for (const rawText of taskTexts) {
    const text = (rawText || '').trim();
    if (!text) continue;
    // 選択済みに同名があれば何もしない(ただし estimate を補完したい既存タスクは別途処理が必要)
    if (selectedBaseNames.has(text)) {
      // estimate 未設定なら補完候補として記録(呼び出し側で saveTasks に反映する想定)
      const t = baseTasks.find(x => x.name === text && alreadySelectedIds.includes(x.id));
      const est = getEst(text);
      if (t && (typeof t.estimateMinutes !== 'number' || t.estimateMinutes <= 0) && typeof est === 'number' && est > 0) {
        estimateUpdatedIds.push(t.id);
      }
      continue;
    }
    // ★ 定例タスク(bottomTasks)と同名なら、新規通常タスクを作らず定例ID として引き継ぐ
    //   これにより「LinkedIn シート更新」のような定例と同名のテキスト入力で
    //   通常タスクが重複生成されるのを防ぐ(2026-04-30 ルール化)
    const matchedRoutine = bottomTasks.find(b => b.name === text);
    if (matchedRoutine) {
      if (!mergedRoutineIds.includes(matchedRoutine.id)) mergedRoutineIds.push(matchedRoutine.id);
      skippedAsRoutine.push(text);
      continue;
    }
    // 既存で同名・自分担当・未完了 → そのIDを追加
    const existing = existingMineUnfinished.find(t => t.name === text);
    if (existing) {
      if (!mergedBaseTaskIds.includes(existing.id)) mergedBaseTaskIds.push(existing.id);
      // estimate 未設定なら補完候補として記録
      const est = getEst(text);
      if ((typeof existing.estimateMinutes !== 'number' || existing.estimateMinutes <= 0) && typeof est === 'number' && est > 0) {
        estimateUpdatedIds.push(existing.id);
      }
      continue;
    }
    // 新規作成
    const est = getEst(text);
    // 担当者名は正準形(「大串」「細川」等)に正規化してプルダウン選択肢と一致させる
    // user.name が「大串 勇輝」(フル表記)でも assignee には「大串」が入る
    const canonicalUser = normalizeName(currentUserName);
    const newTask: Task = {
      id: genId('task'),
      name: text,
      assignee: canonicalUser,
      requester: canonicalUser,
      due: today,
      originalDue: today,
      priority: 3,
      status: '未着手',
      type: 'タスク',
      project: '',
      memo: memoPrefix,
      link: '',
      links: [],
      description: '',
      estimateMinutes: typeof est === 'number' && est > 0 ? est : undefined,
      origin: opts.origin || 'manual',
      todayFlag: true,
      dateChanges: [],
      statusChanges: [],
      editHistory: [],
      comments: [],
      createdBy: canonicalUser,
      createdAt: nowISO(),
      completedDate: null,
      confirmedBy: null,
    };
    newTasks.push(newTask);
    mergedBaseTaskIds.push(newTask.id);
  }

  return { newTasks, mergedBaseTaskIds, mergedRoutineIds, estimateUpdatedIds, skippedAsRoutine };
}

// 名前一致でシートの該当タスクを完了にする(見つからなければ何もしない)
export function markSheetTaskCompleteByName(
  baseTasks: Task[],
  taskName: string,
  currentUserName: string,
  nowIso: string,
): { tasks: Task[]; matched: boolean } {
  // 複数担当(assignees) 込みで判定
  const match = baseTasks.find(t => t.name === taskName && isMineAssignee(t, currentUserName) && t.status !== '完了');
  if (!match) return { tasks: baseTasks, matched: false };
  const today = getTodayDate();
  const next = baseTasks.map(t =>
    t.id === match.id
      ? {
          ...t,
          status: '完了',
          completedDate: today,
          statusChanges: [...(t.statusChanges || []), { from: t.status, to: '完了', user: currentUserName, time: nowIso, reason: '' }],
        }
      : t,
  );
  return { tasks: next, matched: true };
}

// IDで指定されたシートタスクを完了にする
export function markSheetTaskCompleteById(
  baseTasks: Task[],
  taskId: string,
  currentUserName: string,
  nowIso: string,
): Task[] {
  const today = getTodayDate();
  return baseTasks.map(t =>
    t.id === taskId
      ? {
          ...t,
          status: '完了',
          completedDate: today,
          statusChanges: [...(t.statusChanges || []), { from: t.status, to: '完了', user: currentUserName, time: nowIso, reason: '' }],
        }
      : t,
  );
}

// シートタスクのステータスを変更(完了以外)
export function updateSheetTaskStatus(
  baseTasks: Task[],
  taskId: string,
  newStatus: string,
  currentUserName: string,
  nowIso: string,
): Task[] {
  return baseTasks.map(t => {
    if (t.id !== taskId) return t;
    if (t.status === newStatus) return t;
    return {
      ...t,
      status: newStatus,
      completedDate: newStatus === '完了' ? getTodayDate() : (newStatus === '未着手' ? null : t.completedDate),
      statusChanges: [...(t.statusChanges || []), { from: t.status, to: newStatus, user: currentUserName, time: nowIso, reason: '' }],
    };
  });
}

// 定例タスクを「今日実行した」扱いにする(lastDoneを今日に)
export function markRoutineDoneToday(bottomTasks: BottomTask[], routineId: string): BottomTask[] {
  const today = getTodayDate();
  return bottomTasks.map(b => b.id === routineId ? { ...b, lastDone: today } : b);
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理

タスク自動登録

:LiTarget: 用途

メール・Slack・カレンダーからタスクを自動抽出して登録するロジック。

:LiSparkle: 特徴

  • 複数ソース対応
  • AI抽出
  • 重複除去

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: lib/auto-register-task.ts の中身そのもの。コピペ即可。

// 手入力したタスク(朝日報・作業開始・作業終了 等の自由記述)を
// タスクシートに自動登録するヘルパー。
// 重複(同じ名前の自分担当・未完了タスク)がある場合は既存IDを返す。

import { isMineAssignee, type Task, type BottomTask } from './tasks-api';
import { getTodayDate, nowISO, genId } from './work-log';
import { normalizeName } from './normalize-name';

export interface AutoRegisterOptions {
  taskTexts: string[];                  // 登録したいタスク名の配列
  baseTasks: Task[];                    // 現在のタスクシート
  currentUserName: string;              // ログイン中ユーザー名
  alreadySelectedIds?: string[];        // 既に baseTaskIds として選択済みのタスクID
  alreadySelectedRoutineIds?: string[]; // 既に selectedRoutineIds として選択済みの定例タスクID
  defaultDue?: string;                  // 期日(未指定なら今日)
  memoPrefix?: string;                  // memo欄のプリフィックス(例: '朝日報で追加')
  origin?: string;                      // 由来: morning / active / end / manual / slack-bot 等
  // タスク名 → 推定時間(分) のマップ(カレンダー由来など、自動でセットしたい場合)
  // 既存タスクで estimateMinutes が未設定のものにも適用する
  estimateMinutesByName?: Map<string, number> | Record<string, number>;
  // 定例タスクと同名のテキスト入力は、新規通常タスクを作らず定例ID として返す
  // (routine と通常タスクの重複生成を防ぐ)
  bottomTasks?: BottomTask[];
}

export interface AutoRegisterResult {
  newTasks: Task[];          // 新規作成されたタスク
  mergedBaseTaskIds: string[]; // 自動登録後の baseTaskIds(既存ID + 新規ID)
  mergedRoutineIds: string[];  // 自動登録後の selectedRoutineIds(routine 同名で引き継いだIDも含む)
  estimateUpdatedIds: string[]; // 既存タスクで estimateMinutes が新たにセットされた ID(カレンダー由来など)
  skippedAsRoutine: string[];   // routine 同名でスキップされたタスク名(デバッグ用)
}

export function autoRegisterMemoTasks(opts: AutoRegisterOptions): AutoRegisterResult {
  const {
    taskTexts,
    baseTasks,
    currentUserName,
    alreadySelectedIds = [],
    alreadySelectedRoutineIds = [],
    defaultDue,
    memoPrefix = 'メモから自動登録',
    estimateMinutesByName,
    bottomTasks = [],
  } = opts;

  const today = defaultDue || getTodayDate();
  // 複数担当(assignees) 込みで判定
  const existingMineUnfinished = baseTasks.filter(t => isMineAssignee(t, currentUserName) && t.status !== '完了');
  const selectedBaseNames = new Set(baseTasks.filter(t => alreadySelectedIds.includes(t.id)).map(t => t.name));

  // estimateMinutesByName を統一的に扱うためのアクセサ
  const getEst = (name: string): number | undefined => {
    if (!estimateMinutesByName) return undefined;
    if (estimateMinutesByName instanceof Map) return estimateMinutesByName.get(name);
    return (estimateMinutesByName as Record<string, number>)[name];
  };

  const newTasks: Task[] = [];
  const mergedBaseTaskIds = [...alreadySelectedIds];
  const mergedRoutineIds = [...alreadySelectedRoutineIds];
  const estimateUpdatedIds: string[] = [];
  const skippedAsRoutine: string[] = [];

  for (const rawText of taskTexts) {
    const text = (rawText || '').trim();
    if (!text) continue;
    // 選択済みに同名があれば何もしない(ただし estimate を補完したい既存タスクは別途処理が必要)
    if (selectedBaseNames.has(text)) {
      // estimate 未設定なら補完候補として記録(呼び出し側で saveTasks に反映する想定)
      const t = baseTasks.find(x => x.name === text && alreadySelectedIds.includes(x.id));
      const est = getEst(text);
      if (t && (typeof t.estimateMinutes !== 'number' || t.estimateMinutes <= 0) && typeof est === 'number' && est > 0) {
        estimateUpdatedIds.push(t.id);
      }
      continue;
    }
    // ★ 定例タスク(bottomTasks)と同名なら、新規通常タスクを作らず定例ID として引き継ぐ
    //   これにより「LinkedIn シート更新」のような定例と同名のテキスト入力で
    //   通常タスクが重複生成されるのを防ぐ(2026-04-30 ルール化)
    const matchedRoutine = bottomTasks.find(b => b.name === text);
    if (matchedRoutine) {
      if (!mergedRoutineIds.includes(matchedRoutine.id)) mergedRoutineIds.push(matchedRoutine.id);
      skippedAsRoutine.push(text);
      continue;
    }
    // 既存で同名・自分担当・未完了 → そのIDを追加
    const existing = existingMineUnfinished.find(t => t.name === text);
    if (existing) {
      if (!mergedBaseTaskIds.includes(existing.id)) mergedBaseTaskIds.push(existing.id);
      // estimate 未設定なら補完候補として記録
      const est = getEst(text);
      if ((typeof existing.estimateMinutes !== 'number' || existing.estimateMinutes <= 0) && typeof est === 'number' && est > 0) {
        estimateUpdatedIds.push(existing.id);
      }
      continue;
    }
    // 新規作成
    const est = getEst(text);
    // 担当者名は正準形(「大串」「細川」等)に正規化してプルダウン選択肢と一致させる
    // user.name が「大串 勇輝」(フル表記)でも assignee には「大串」が入る
    const canonicalUser = normalizeName(currentUserName);
    const newTask: Task = {
      id: genId('task'),
      name: text,
      assignee: canonicalUser,
      requester: canonicalUser,
      due: today,
      originalDue: today,
      priority: 3,
      status: '未着手',
      type: 'タスク',
      project: '',
      memo: memoPrefix,
      link: '',
      links: [],
      description: '',
      estimateMinutes: typeof est === 'number' && est > 0 ? est : undefined,
      origin: opts.origin || 'manual',
      todayFlag: true,
      dateChanges: [],
      statusChanges: [],
      editHistory: [],
      comments: [],
      createdBy: canonicalUser,
      createdAt: nowISO(),
      completedDate: null,
      confirmedBy: null,
    };
    newTasks.push(newTask);
    mergedBaseTaskIds.push(newTask.id);
  }

  return { newTasks, mergedBaseTaskIds, mergedRoutineIds, estimateUpdatedIds, skippedAsRoutine };
}

// 名前一致でシートの該当タスクを完了にする(見つからなければ何もしない)
export function markSheetTaskCompleteByName(
  baseTasks: Task[],
  taskName: string,
  currentUserName: string,
  nowIso: string,
): { tasks: Task[]; matched: boolean } {
  // 複数担当(assignees) 込みで判定
  const match = baseTasks.find(t => t.name === taskName && isMineAssignee(t, currentUserName) && t.status !== '完了');
  if (!match) return { tasks: baseTasks, matched: false };
  const today = getTodayDate();
  const next = baseTasks.map(t =>
    t.id === match.id
      ? {
          ...t,
          status: '完了',
          completedDate: today,
          statusChanges: [...(t.statusChanges || []), { from: t.status, to: '完了', user: currentUserName, time: nowIso, reason: '' }],
        }
      : t,
  );
  return { tasks: next, matched: true };
}

// IDで指定されたシートタスクを完了にする
export function markSheetTaskCompleteById(
  baseTasks: Task[],
  taskId: string,
  currentUserName: string,
  nowIso: string,
): Task[] {
  const today = getTodayDate();
  return baseTasks.map(t =>
    t.id === taskId
      ? {
          ...t,
          status: '完了',
          completedDate: today,
          statusChanges: [...(t.statusChanges || []), { from: t.status, to: '完了', user: currentUserName, time: nowIso, reason: '' }],
        }
      : t,
  );
}

// シートタスクのステータスを変更(完了以外)
export function updateSheetTaskStatus(
  baseTasks: Task[],
  taskId: string,
  newStatus: string,
  currentUserName: string,
  nowIso: string,
): Task[] {
  return baseTasks.map(t => {
    if (t.id !== taskId) return t;
    if (t.status === newStatus) return t;
    return {
      ...t,
      status: newStatus,
      completedDate: newStatus === '完了' ? getTodayDate() : (newStatus === '未着手' ? null : t.completedDate),
      statusChanges: [...(t.statusChanges || []), { from: t.status, to: newStatus, user: currentUserName, time: nowIso, reason: '' }],
    };
  });
}

// 定例タスクを「今日実行した」扱いにする(lastDoneを今日に)
export function markRoutineDoneToday(bottomTasks: BottomTask[], routineId: string): BottomTask[] {
  const today = getTodayDate();
  return bottomTasks.map(b => b.id === routineId ? { ...b, lastDone: today } : b);
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/lib/auto-register-task.ts

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加