0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

n8nでAsanaのタスクとコメントをSlackとChatworkに通知させるワークフローを作ったよ

Posted at

コードはあまりかけないけど、GPTと自動化を愛する上司に恵まれれば、エンジニアじゃなくても作れるらしい…。

何をするn8nか

Asanaから前日終了タスクとコメントありタスク、期限3日前タスクを拾って通知する

image.png
こういうのですね。

目的は何か

関係者が朝一で前日と当日のタスクを把握する

あまりにもいろいろなところから連絡がくると
「もういいや、誰かが把握しておいてくれるだろう」
と思考停止に陥りがちなもの。
最悪のパターンとして
「自分の手持ちタスクの期限も忘れたけど、だれかリマインドしてくれるんだろう」
というのがあります…ね。

それを防ぎたかったのです。

通知に含めたもの

  • サマリー
    • 前日に完了したタスクの件数
    • コメントが入ったタスクの件数
    • 通知当日が期限のタスクの件数
    • 3日以内に期限がくるタスクの件数
  • 前日に終わったタスクのタスク名
  • 前日にコメントのあったタスクのタスク名
  • 今日期限のタスクの具体名
  • 3日以内期限のタスクの具体名・期限・担当者

ワークフローとコード

ワークフロー

image.png
Asanaから情報をもってきて、欲しい情報だけを抽出して、それぞれを出力用に整形して、整形したものをマージして、最終的に整えて、通知するという流れになってます。

具体的には以下のような処理が順番に実行されます:

  • 日付計算:日本時間基準で「昨日」「今日」「3日以内」の期間を計算
  • Asana API呼び出し:各期間ごとにタスク情報を取得
  • データフィルタリング:タスクタイプ(完了タスク、コメントタスク、期限タスク)ごとに分類
  • メッセージ作成:分類されたデータをSlack用にフォーマット
  • 通知送信:最終的なメッセージをSlackに投

コード

GPTと上司に助けてもらいました。
コードの意味もよくわからないようではまずいので、これを記事にするにあたってGPTにコードの解説も依頼しています。

以下、ワークフローの左側からコードをコピペしていきますね。

Code - Build Window (JST→UTC)1(一番左)

image.png
このコードの役割:Asana APIは世界中で使われているため、内部ではUTC(世界標準時)で管理されています。しかし私たちは日本時間で「昨日」「今日」を考えています。そこでこのコードは日本時間基準で「昨日の00:00〜23:59」「今日の00:00〜23:59」といった期間を正確に計算し、Asana APIに渡すための形式に変換します。これがないと、日本時間と世界標準時のズレで「昨日のはずのタスク」が「今日扱い」になったりしてしまいます。

  • JST固定で日付計算
  • 開始・終了時刻を正確に設定(00:00〜23:59:59.999)
  • ISO 8601形式+タイムゾーンオフセットでAsana APIと連携
  • タスクタイプごとに期間を変えて配列で返す
// JSTタイムゾーン
const tz = 'Asia/Tokyo';
// 現在日時(JST)
const nowJst = new Date(new Date().toLocaleString('en-US', { timeZone: tz }));

// 昨日のJST 00:00
const yesterdayStartJst = new Date(nowJst);
yesterdayStartJst.setDate(yesterdayStartJst.getDate() - $input.first().json.dateMinus);
yesterdayStartJst.setHours(0, 0, 0, 0);
// 昨日のJST 23:59:59
const yesterdayEndJst = new Date(yesterdayStartJst);
yesterdayEndJst.setHours(23, 59, 59, 999);

// 今日のJST 00:00
const todayStartJst = new Date(nowJst);
todayStartJst.setHours(0, 0, 0, 0);
// 今日のJST 23:59:59
const todayEndJst = new Date(todayStartJst);
todayEndJst.setHours(23, 59, 59, 999);

// 3日後のJST 23:59:59(今日から3日以内の期限)
const threeDaysLaterJst = new Date(todayStartJst);
threeDaysLaterJst.setDate(threeDaysLaterJst.getDate() + 3);
threeDaysLaterJst.setHours(23, 59, 59, 999);

// JSTのISO8601文字列(ローカルタイムゾーン +09:00 を保持)
function toIsoWithOffset(date, offsetHours) {
  const tzOffsetMs = offsetHours * 60 * 60 * 1000;
  const local = new Date(date.getTime());
  const pad = (n) => String(n).padStart(2, '0');
  const yyyy = local.getFullYear();
  const mm = pad(local.getMonth() + 1);
  const dd = pad(local.getDate());
  const hh = pad(local.getHours());
  const mi = pad(local.getMinutes());
  const ss = pad(local.getSeconds());
  const offsetSign = offsetHours >= 0 ? '+' : '-';
  const absOffsetHours = pad(Math.floor(Math.abs(offsetHours)));
  const absOffsetMinutes = pad(Math.abs(offsetHours * 60) % 60);
  return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${offsetSign}${absOffsetHours}:${absOffsetMinutes}`;
}

// 各タスクタイプのデータを準備
return [
  // 1. 昨日終わったタスクの一覧(既存)
  {
    json: {
      taskType: 'completed_yesterday',
      asanaWorkspaceId: $input.first().json.asanaWorkspaceId,
      asanaProjectId: $input.first().json.asanaProjectId,
      startIso: toIsoWithOffset(yesterdayStartJst, 9),
      endIso: toIsoWithOffset(yesterdayEndJst, 9)
    }
  },
  // 2. 昨日コメントのあったタスク
  {
    json: {
      taskType: 'commented_yesterday',
      asanaWorkspaceId: $input.first().json.asanaWorkspaceId,
      asanaProjectId: $input.first().json.asanaProjectId,
      startIso: toIsoWithOffset(yesterdayStartJst, 9),
      endIso: toIsoWithOffset(yesterdayEndJst, 9)
    }
  },
  // 3. 今日期限のタスク
  {
    json: {
      taskType: 'due_today',
      asanaWorkspaceId: $input.first().json.asanaWorkspaceId,
      asanaProjectId: $input.first().json.asanaProjectId,
      startIso: toIsoWithOffset(todayStartJst, 9),
      endIso: toIsoWithOffset(todayEndJst, 9)
    }
  },
  // 4. 今日から3日以内に期限を迎えるタスク
  {
    json: {
      taskType: 'due_within_3_days',
      asanaWorkspaceId: $input.first().json.asanaWorkspaceId,
      asanaProjectId: $input.first().json.asanaProjectId,
      startIso: toIsoWithOffset(todayStartJst, 9),
      endIso: toIsoWithOffset(threeDaysLaterJst, 9)
    }
  }
];

Code1(真ん中上)

このコードの役割:Asana APIから返ってきたタスクの中から「今日が期限のタスク」だけを抽出します。Asanaのタスクデータは due_on という項目に「YYYY-MM-DD」形式で期限が記録されているので、それが今日の日付と一致していて、かつ完了していないものを探し出します。

  • 今日期限のタスクだけを抽出する処理
  • UTCとJSTの差に注意(日本時間基準で正確にやるなら調整が必要)
  • flatMap を使うことで複数入力アイテムのデータを1つにまとめられる
  • 出力は { json: task } の配列。そのまま次の処理に渡す
// 今日の日付を取得(YYYY-MM-DD形式)
const today = new Date().toISOString().split('T')[0];

// 今日期限のタスクをフィルタリング
const todayTasks = $input.all().flatMap(item => {
  if (item.json.data) {
    return item.json.data.filter(task => {
      return task.due_on === today && !task.completed;
    });
  }
  return [];
});

return todayTasks.map(task => ({ json: task }));

Code2(真ん中下)

このコードの役割:「昨日すでに期限が過ぎたタスク」ではなく「これからやってくる期限」を事前にキャッチします。今日から3日以内、つまり今日・明日・明後日・その次の日に期限があるタスクを拾い出します。チームが「あ、もうすぐ期限か」と認識できるので、期限切れを防げます。

  • 今日から3日以内に期限のある未完了タスクを取得する
  • due_on は YYYY-MM-DD形式 の文字列として比較
// 今日から3日後の日付を取得
const today = new Date();
const threeDaysLater = new Date(today);
threeDaysLater.setDate(today.getDate() + 3);

const todayStr = today.toISOString().split('T')[0];
const threeDaysLaterStr = threeDaysLater.toISOString().split('T')[0];

// 3日以内期限のタスクをフィルタリング
const upcomingTasks = $input.all().flatMap(item => {
  if (item.json.data) {
    return item.json.data.filter(task => {
      if (!task.due_on || task.completed) return false;
      return task.due_on >= todayStr && task.due_on <= threeDaysLaterStr;
    });
  }
  return [];
});

return upcomingTasks.map(task => ({ json: task }));

Code(右端)

このコードの役割:ここまでで集めたデータ(完了タスク、コメントタスク、期限タスク)を分類し、Slack用の見やすいメッセージにまとめます。

  • タスクタイプごとに分類 して配列に格納
  • JSONパースに対応(文字列、配列、単一オブジェクト)
  • 昨日完了・コメントタスクは日付でフィルタリング
  • Slackメッセージ形式で出力(サマリー + 各タスク詳細)
  • 担当者や期限がない場合のデフォルト表示も考慮
// 各タスクタイプのデータを分類
const completedTasks = [];
const commentedTasks = [];
const dueTodayTasks = [];
const dueWithin3DaysTasks = [];

$input.all().forEach(item => {
  const data = item.json;
  
  if (data.taskType === 'completed_yesterday') {
    let tasks = [];
    
    if (data.tasks) {
      try {
        if (typeof data.tasks === 'string') {
          tasks = JSON.parse(data.tasks);
        } else if (Array.isArray(data.tasks)) {
          tasks = data.tasks;
        }
      } catch (e) {
        console.log('tasksのパースに失敗:', e);
      }
    }
    
    if (tasks.length === 0 && data.originalData) {
      try {
        if (typeof data.originalData === 'string') {
          const parsed = JSON.parse(data.originalData);
          if (parsed.data && Array.isArray(parsed.data)) {
            tasks = parsed.data;
          }
        } else if (data.originalData.data && Array.isArray(data.originalData.data)) {
          tasks = parsed.data;
        }
      } catch (e) {
        console.log('originalDataのパースに失敗:', e);
      }
    }
    
    // 昨日完了したタスクのみをフィルタリング
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    const yesterdayStr = yesterday.toISOString().split('T')[0];
    
    const yesterdayCompletedTasks = tasks.filter(task => {
      if (task.completed_at) {
        const completedDate = task.completed_at.split('T')[0];
        return completedDate === yesterdayStr;
      }
      return false;
    });
    
    completedTasks.push(...yesterdayCompletedTasks);
    
  } else if (data.taskType === 'commented_yesterday') {
    let tasks = [];
    
    if (data.tasks) {
      try {
        if (typeof data.tasks === 'string') {
          tasks = JSON.parse(data.tasks);
        } else if (Array.isArray(data.tasks)) {
          tasks = data.tasks;
        }
      } catch (e) {
        console.log('tasksのパースに失敗:', e);
      }
    }
    
    if (tasks.length === 0 && data.originalData) {
      try {
        if (typeof data.originalData === 'string') {
          const parsed = JSON.parse(data.originalData);
          if (parsed.data && Array.isArray(parsed.data)) {
            tasks = parsed.data;
          }
        } else if (data.originalData.data && Array.isArray(data.originalData.data)) {
          tasks = parsed.data;
        }
      } catch (e) {
        console.log('originalDataのパースに失敗:', e);
      }
    }
    
    // 昨日コメントがあったタスクのみをフィルタリング
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    const yesterdayStr = yesterday.toISOString().split('T')[0];
    
    const yesterdayCommentedTasks = tasks.filter(task => {
      if (task.modified_at) {
        const modifiedDate = task.modified_at.split('T')[0];
        return modifiedDate === yesterdayStr;
      }
      return false;
    });
    
    commentedTasks.push(...yesterdayCommentedTasks);
    
  } else if (data.taskType === 'due_today') {
    let tasks = [];
    
    if (data.tasks) {
      try {
        if (typeof data.tasks === 'string') {
          const parsed = JSON.parse(data.tasks);
          if (Array.isArray(parsed)) {
            tasks = parsed;
          } else {
            tasks = [parsed];
          }
        } else if (Array.isArray(data.tasks)) {
          tasks = data.tasks;
        } else if (data.tasks.name) {
          tasks = [data.tasks];
        }
      } catch (e) {
        console.log('due_todayのパースに失敗:', e);
      }
    }
    
    dueTodayTasks.push(...tasks);
    
  } else if (data.taskType === 'due_within_3_days') {
    let tasks = [];
    
    if (data.tasks) {
      try {
        if (typeof data.tasks === 'string') {
          const parsed = JSON.parse(data.tasks);
          if (Array.isArray(parsed)) {
            tasks = parsed;
          } else {
            tasks = [parsed];
          }
        } else if (Array.isArray(data.tasks)) {
          tasks = data.tasks;
        } else if (data.tasks.name) {
          tasks = [data.tasks];
        }
      } catch (e) {
        console.log('due_within_3_daysのパースに失敗:', e);
      }
    }
    
    dueWithin3DaysTasks.push(...tasks);
  }
});

// 現在の日付を取得
const today = new Date().toLocaleDateString('ja-JP', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  weekday: 'long'
});

// メッセージを構築
let message = `📊 *ASANA Daily Report - ${today}*\n\n`;

// サマリーを最初に表示
message += `✏️ *サマリー*\n`;
message += `   完了: ${completedTasks.length}件\n`;
message += `   コメント: ${commentedTasks.length}件\n`;
message += `   今日期限: ${dueTodayTasks.length}件\n`;
message += `   3日以内期限: ${dueWithin3DaysTasks.length}件\n\n`;

// 昨日終わったタスク
message += "✅ *昨日終わったタスク*\n";
if (completedTasks.length > 0) {
  completedTasks.forEach(task => {
    message += `・${task.name} (担当者: ${task.assignee?.name || '未割当'})\n`;
  });
} else {
  message += "   なし\n";
}
message += "\n";

// 昨日コメントのあったタスク
message += "💬 *昨日コメントのあったタスク*\n";
if (commentedTasks.length > 0) {
  commentedTasks.forEach(task => {
    message += `・${task.name} (担当者: ${task.assignee?.name || '未割当'})\n`;
  });
} else {
  message += "   なし\n";
}
message += "\n";

// 今日期限のタスク
message += "⚠️ *今日期限のタスク*\n";
if (dueTodayTasks.length > 0) {
  dueTodayTasks.forEach(task => {
    message += `・${task.name} (担当者: ${task.assignee?.name || '未割当'})\n`;
  });
} else {
  message += "   なし ✨\n";
}
message += "\n";

// 3日以内期限のタスク
message += "📅 *3日以内期限のタスク*\n";
if (dueWithin3DaysTasks.length > 0) {
  dueWithin3DaysTasks.forEach(task => {
    message += `・${task.name}`;
    if (task.due_on) {
      message += ` (期限: ${task.due_on})`;
    }
    message += ` (担当者: ${task.assignee?.name || '未割当'})\n`;
  });
} else {
  message += "   なし ✨\n";
}

return [{
  json: {
    slackMessage: message,
    totalTasks: completedTasks.length + commentedTasks.length + dueTodayTasks.length + dueWithin3DaysTasks.length
  }
}];

ボトルネック

Asanaの情報が最新かつ正しい必要がある

「Asana入力しました?」とAsanaハラスメントをする人がいるか、
「Asanaには私が入力するから他の人は入力しないで」とAsanaの犠牲者になる人がいるか
「Asana入力するのだい好きっす」とAsana愛に溢れるメンバーしかいないか
じゃないと一気に崩壊します。

私の場合は、そこまで大きくない案件だったこともあり、私がAsanaに入力し続けることで実現。

余談

あちこちから連絡がくる(複数案件)だと情報におぼれがち

私、Webディレクターです。
作業の進捗管理とスケジュール調整、たまにコードや記事を書きながら生きています。

あらゆるところから連絡が飛んできて、様々なタスクがゴリゴリ進行。
「あれ?あのタスク終わったっけ?今日までの進捗どうだっけ?」
と、自分でいうのはいいけれど、たまーに
「あれどうなってましたっけ??」
が飛んでくる。

関係者は数多くいるけれど、どうやら全体の進行状況とやりとりの温度感を把握しているのは自分だけっぽい。

「細かい部分は目視確認しに行くけど、ざっくり状況把握できるようにしたい」と作ったのがこのn8n。「ちょっと今から説明します」という感じで、一瞬で状況がわかる挙動をしてくれればいいやと思って作りましたが、割とよかったです。

朝Slackを開いた時点で「昨日の進捗」「今日やること」がまとめて届くので、会議前に瞬時に全体像が頭に入ります。特に複数案件を並行している時は、このレポートがあるだけで精神的な負担が減ります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?