タスク API レイヤー
:LiTarget: 用途
タスク CRUD + KV キャッシュ + リアルタイム同期の API レイヤー。
:LiSparkle: 特徴
- CRUD操作
- KVキャッシュ
- リアルタイム同期
- 楽観的更新
:LiCode: 実コード(SCALE Base より自動抽出)
:LiInfo:
lib/tasks-api.tsxの中身そのもの。コピペ即可。
'use client';
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
// =========== CONFIG ===========
const API_BASE = 'https://scale-task-cron.y-ogushi.workers.dev';
// =========== TYPES ===========
export interface DateChange {
from: string;
to: string;
user: string;
time: string;
reason: string;
}
export interface StatusChange {
from: string;
to: string;
user: string;
time: string;
reason: string;
}
export interface TaskComment {
user: string;
text: string;
time: string;
}
export interface Task {
id: string;
name: string;
assignee: string;
// 複数担当対応(assignees)。後方互換のため単数 assignee も維持し、両方を join('、') 等で統合可能。
assignees?: string[];
requester: string;
due: string;
originalDue: string;
priority: number;
status: string;
type: string;
project: string;
memo: string;
link: string;
// 詳細説明(何のタスクか・完了条件・注意点)。Slack連携時にClaudeで自動生成可
description?: string;
// 関連リンク(複数)。link は単数の後方互換用、links は配列
links?: { label?: string; url: string }[];
// 想定所要時間(分)
estimateMinutes?: number;
// 由来(どこから追加されたか): 'manual' | 'morning' | 'active' | 'end' | 'slack-bot' | 'routine'
origin?: string;
todayFlag: boolean;
dateChanges: DateChange[];
statusChanges: StatusChange[];
editHistory: { field: string; from: string; to: string; user: string; time: string }[];
comments: TaskComment[];
createdBy: string;
createdAt: string;
completedDate: string | null;
confirmedBy: string | null;
fmt?: string;
}
export interface Project {
id: string;
name: string;
owner: string;
status: string;
startDate: string;
endDate: string;
memo: string;
goal: string;
}
export interface BottomTask {
id: string;
name: string;
assignee: string;
frequency: string;
due: string; // 後方互換。現在は dayOfWeek / dayOfMonth を優先
dayOfWeek?: number; // 0=日 〜 6=土(weekly / biweekly 用)
dayOfMonth?: number; // 1-31 または -1=月末(monthly 用)
project?: string; // 紐付くPJTのID
memo: string;
link?: string; // 関連URL(LinkedInアカウント / 使用ツール等)。作業中画面で🔗ボタン表示
estimateMinutes?: number; // 目安作業時間(分)。朝日報/作業開始の合計表示で使用
lastDone: string | null;
createdAt: string;
}
export interface TaskMember {
id: string;
name: string;
pass: string;
role: string;
icon?: string; // 絵文字アイコン(例: '🐻')。未設定時は名前の頭文字
iconBg?: string; // アイコン背景色(HEX)。未設定時は名前ベースのグラデ
}
export const MEMBER_EMOJI_PRESETS = [
'🐻', '🐼', '🦊', '🐱', '🐶', '🐰', '🐯', '🐸', '🐷', '🐵',
'🦄', '🦁', '🐨', '🐹', '🐺', '🐧', '🦉', '🐙', '🦝', '🐠',
'🦋', '🐝', '🐢', '🦖', '🐳', '🦈', '🐉', '🦚',
'🍎', '🍊', '🍋', '🍓', '🥑', '🍕', '🍔', '🍩', '🍪', '🎂',
'⭐️', '✨', '🔥', '❄️', '💎', '🌙', '☀️', '⚡️', '🌈', '🎯',
'🚀', '🤖', '👻', '🎨', '🎪', '🏆', '💡', '🎮', '📚', '🎸',
];
export const MEMBER_BG_PRESETS = [
'#fde2d3', '#fdd4e8', '#e8d9f7', '#d1ebf5', '#d4f0dd',
'#f7ecc9', '#edd5f2', '#d4def0', '#f0dbc9', '#e0e0e5',
'#ffc8a8', '#ffcadb', '#d4c0ea', '#b5ddee', '#bfe6ca',
'#f3ddaa', '#e2b9ee', '#b9ccea', '#e8bfa0', '#c9c9d0',
];
export interface AuditEntry {
id: string;
user: string;
action: string;
detail: string;
taskId: string;
time: string;
}
export interface SlackScanResult {
tasks: { name: string; assignee: string; due: string; priority: number; channel: string; summary: string; source_user: string }[];
channel: string;
messages: number;
scannedAt: string;
addedCount?: number;
error?: string;
}
type HistoryEntryType = 'tasks' | 'projects' | 'bottom_tasks' | 'members';
interface HistoryEntry {
type: HistoryEntryType;
before: Task[] | Project[] | BottomTask[] | TaskMember[];
time: number;
label?: string;
}
// =========== CONSTANTS ===========
export const STATUSES = ['未着手', '進行中', '確認待ち', '完了', '保留', '中止'];
export const STATUS_COLORS: Record<string, string> = {
'未着手': 'text-gray-400 bg-gray-500/10 border-gray-500/30',
'進行中': 'text-blue-400 bg-blue-500/10 border-blue-500/30',
'確認待ち': 'text-orange-400 bg-orange-500/10 border-orange-500/30',
'完了': 'text-green-400 bg-green-500/10 border-green-500/30',
'保留': 'text-purple-400 bg-purple-500/10 border-purple-500/30',
'中止': 'text-red-400 bg-red-500/10 border-red-500/30',
};
export const TYPES = ['開発', 'デザイン', '営業', '事務', '確認', 'MTG', 'その他'];
export const ROLES: Record<string, string> = { pmo: 'PMO(管理者)', manager: 'マネージャー', member: 'メンバー' };
// 「毎日」= 平日毎日(月〜金)扱い。土日は休み運用
export const FREQ_LABELS: Record<string, string> = { daily: '毎日(平日)', weekly: '毎週', biweekly: '隔週', monthly: '毎月', quarter: '四半期', adhoc: '随時' };
export const FREQ_COLORS: Record<string, string> = {
daily: 'text-red-400 bg-red-500/10 border-red-500/30',
weekly: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
biweekly: 'text-purple-400 bg-purple-500/10 border-purple-500/30',
monthly: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
quarter: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
adhoc: 'text-gray-400 bg-gray-500/10 border-gray-500/30',
};
export const DEFAULT_MEMBERS: TaskMember[] = [
{ id: 'm1', name: '大串', pass: 'scale2024', role: 'pmo' },
{ id: 'm2', name: '細川', pass: 'scale2024', role: 'pmo' },
];
// =========== HELPERS ===========
// 担当者リストを取得(assignees 優先、無ければ assignee 単数を1件配列にする)
export function getAssigneeList(t: { assignee?: string; assignees?: string[] }): string[] {
if (Array.isArray(t.assignees) && t.assignees.length > 0) return t.assignees.filter(Boolean);
if (t.assignee && t.assignee.trim()) return [t.assignee.trim()];
return [];
}
// 担当者を「、」区切りで表示
export function formatAssignees(t: { assignee?: string; assignees?: string[] }): string {
return getAssigneeList(t).join('、');
}
// 自分担当か判定(部分一致 / 双方向)
export function isMineAssignee(t: { assignee?: string; assignees?: string[] }, myName: string): boolean {
if (!myName) return false;
return getAssigneeList(t).some(a => a === myName || a.includes(myName) || myName.includes(a));
}
export function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
export function today(): string {
return new Date().toISOString().split('T')[0];
}
export function now(): string {
return new Date().toISOString().slice(0, 16).replace('T', ' ');
}
export function fmtDate(d: string): string {
if (!d) return '-';
const dt = new Date(d + 'T00:00:00');
return `${dt.getMonth() + 1}/${dt.getDate()}`;
}
export function daysDiff(d: string): number {
if (!d) return 0;
const t = new Date(today());
const dd = new Date(d);
return Math.floor((t.getTime() - dd.getTime()) / 86400000);
}
export const DAYS_OF_WEEK_JA = ['日', '月', '火', '水', '木', '金', '土'];
// 月曜起点の週始まりを返す
function mondayOf(d: Date): Date {
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day); // 0=日曜→-6, 1=月曜→0, 2=火曜→-1, ...
const m = new Date(d);
m.setDate(d.getDate() + diff);
m.setHours(0, 0, 0, 0);
return m;
}
/**
* 定例タスクが「現在の周期内」で完了済みかを判定。
* - daily/weekdays → 今日完了したか
* - weekly → 同じ週(月曜起点)内で完了したか
* - biweekly → 最終実施から14日未満
* - monthly → 同じ月内で完了したか
* - quarter → 同じ四半期内で完了したか
* - adhoc → 常に false(リセット不要、手動運用)
*/
export function isRoutineDoneInCurrentPeriod(bt: BottomTask, now: Date = new Date()): boolean {
if (!bt.lastDone) return false;
const lastDone = new Date(bt.lastDone + 'T00:00:00');
if (isNaN(lastDone.getTime())) return false;
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
switch (bt.frequency) {
case 'daily':
case 'weekdays':
return sameDay(lastDone, now);
case 'weekly': {
const aMon = mondayOf(lastDone);
const bMon = mondayOf(now);
return sameDay(aMon, bMon);
}
case 'biweekly': {
const dayDiff = Math.floor((now.getTime() - lastDone.getTime()) / 86400000);
return dayDiff < 14;
}
case 'monthly':
return lastDone.getFullYear() === now.getFullYear() && lastDone.getMonth() === now.getMonth();
case 'quarter':
return lastDone.getFullYear() === now.getFullYear() && Math.floor(lastDone.getMonth() / 3) === Math.floor(now.getMonth() / 3);
case 'adhoc':
return false; // 随時は常に未完了扱い(リセット制御しない)
default:
return sameDay(lastDone, now);
}
}
// ───── 日本の祝日(2026-2028年)─────
// 給与振込・経理など「営業日」基準の判定で使用。年次でメンテナンス必須。
// 出典: 内閣府「国民の祝日に関する法律」/ 振替休日含む
const JAPAN_HOLIDAYS = new Set<string>([
// 2026
'2026-01-01', '2026-01-12', '2026-02-11', '2026-02-23', '2026-03-20',
'2026-04-29', '2026-05-03', '2026-05-04', '2026-05-05', '2026-05-06',
'2026-07-20', '2026-08-11', '2026-09-21', '2026-09-22', '2026-09-23',
'2026-10-12', '2026-11-03', '2026-11-23',
// 2027
'2027-01-01', '2027-01-11', '2027-02-11', '2027-02-23', '2027-03-21', '2027-03-22',
'2027-04-29', '2027-05-03', '2027-05-04', '2027-05-05',
'2027-07-19', '2027-08-11', '2027-09-20', '2027-09-23', '2027-10-11', '2027-11-03', '2027-11-23',
// 2028
'2028-01-01', '2028-01-10', '2028-02-11', '2028-02-23', '2028-03-20',
'2028-04-29', '2028-05-03', '2028-05-04', '2028-05-05',
'2028-07-17', '2028-08-11', '2028-09-18', '2028-09-22', '2028-10-09', '2028-11-03', '2028-11-23',
]);
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// 営業日かどうか(土日 + 祝日を除く)
export function isBusinessDay(d: Date): boolean {
const dow = d.getDay();
if (dow === 0 || dow === 6) return false; // 土日
if (JAPAN_HOLIDAYS.has(ymd(d))) return false; // 祝日
return true;
}
// 月の最終営業日(土日祝を遡って最終の営業日を返す)
export function lastBusinessDayOfMonth(year: number, monthIndex: number): number {
// monthIndex は 0-indexed
const lastDay = new Date(year, monthIndex + 1, 0).getDate();
for (let day = lastDay; day >= 1; day--) {
const d = new Date(year, monthIndex, day);
if (isBusinessDay(d)) return day;
}
return lastDay; // 全日休日のレアケースは月末を返す
}
// 定例タスクが今日該当するかを判定(曜日/日付ベース、後方互換で due も見る)
// dayOfMonth: 1-31 = 指定日 / -1 = 月末(暦上) / -2 = 月末(営業日: 土日祝除く)
export function isBottomTaskDueToday(bt: BottomTask): boolean {
const now = new Date();
const dow = now.getDay();
const dom = now.getDate();
if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return dow >= 1 && dow <= 5; // 月〜金のみ(土日休み)
if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
if (typeof bt.dayOfWeek === 'number') return bt.dayOfWeek === dow;
}
if (bt.frequency === 'monthly') {
if (typeof bt.dayOfMonth === 'number') {
if (bt.dayOfMonth === -1) {
// 暦上の月末
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
return dom === lastDay;
}
if (bt.dayOfMonth === -2) {
// 月末営業日(土日祝を除いた月最後の営業日)
return dom === lastBusinessDayOfMonth(now.getFullYear(), now.getMonth());
}
return bt.dayOfMonth === dom;
}
}
// 後方互換: 旧 due フィールド基準
const td = today();
return !!(bt.due === td || (bt.due && bt.due <= td));
}
// スケジュール表示(例: 毎週 火曜日 / 毎月 15日 / 毎月 月末 / 毎月 月末営業日)
export function formatBottomTaskSchedule(bt: BottomTask): string {
if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return '毎日(平日)';
if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
const prefix = bt.frequency === 'biweekly' ? '隔週' : '毎週';
if (typeof bt.dayOfWeek === 'number') return `${prefix} ${DAYS_OF_WEEK_JA[bt.dayOfWeek]}曜日`;
return prefix;
}
if (bt.frequency === 'monthly') {
if (typeof bt.dayOfMonth === 'number') {
if (bt.dayOfMonth === -1) return '毎月 月末';
if (bt.dayOfMonth === -2) return '毎月 月末営業日';
return `毎月 ${bt.dayOfMonth}日`;
}
return '毎月';
}
if (bt.frequency === 'quarter') return '四半期';
return FREQ_LABELS[bt.frequency] ?? bt.frequency;
}
export function memberColor(name: string): string {
const colors = ['from-blue-500 to-blue-700', 'from-red-500 to-red-700', 'from-green-500 to-green-700', 'from-yellow-500 to-yellow-700', 'from-purple-500 to-purple-700', 'from-cyan-500 to-cyan-700', 'from-pink-500 to-pink-700', 'from-indigo-500 to-indigo-700'];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
// =========== API LAYER ===========
async function fetchFromAPI(key: string): Promise<unknown> {
try {
const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
const res = await fetch(url);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function saveToAPI(key: string, value: unknown): Promise<void> {
try {
const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value),
});
} catch {
// silent fail
}
}
// =========== CONTEXT ===========
interface TasksContextType {
tasks: Task[];
projects: Project[];
bottomTasks: BottomTask[];
members: TaskMember[];
auditLog: AuditEntry[];
isLoading: boolean;
currentUser: string;
saveTasks: (tasks: Task[]) => void;
saveProjects: (projects: Project[]) => void;
saveBottomTasks: (bt: BottomTask[]) => void;
saveMembers: (members: TaskMember[]) => void;
addAudit: (action: string, detail: string, taskId?: string) => void;
scanSlack: () => Promise<SlackScanResult | null>;
reload: () => Promise<void>;
undo: () => { ok: boolean; label?: string };
canUndo: boolean;
}
const TasksContext = createContext<TasksContextType | null>(null);
export function TasksProvider({ children, currentUser }: { children: ReactNode; currentUser: string }) {
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [bottomTasks, setBottomTasks] = useState<BottomTask[]>([]);
const [members, setMembers] = useState<TaskMember[]>(DEFAULT_MEMBERS);
const [auditLog, setAuditLog] = useState<AuditEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadAll = useCallback(async () => {
const [t, p, bt, m, al] = await Promise.all([
fetchFromAPI('tasks'),
fetchFromAPI('projects'),
fetchFromAPI('bottom_tasks'),
fetchFromAPI('members'),
fetchFromAPI('audit_log'),
]);
setTasks((t as Task[]) || []);
setProjects((p as Project[]) || []);
setBottomTasks((bt as BottomTask[]) || []);
setMembers((m as TaskMember[]) || DEFAULT_MEMBERS);
setAuditLog((al as AuditEntry[]) || []);
if (!m || !(m as TaskMember[]).length) {
saveToAPI('members', DEFAULT_MEMBERS);
}
setIsLoading(false);
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
// ブラウザがアクティブに戻ったとき / 60秒ごとに再取得(Slack経由の更新を拾う)
useEffect(() => {
const onFocus = () => { loadAll(); };
const onVis = () => { if (document.visibilityState === 'visible') loadAll(); };
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVis);
const interval = setInterval(() => { loadAll(); }, 60000);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVis);
clearInterval(interval);
};
}, [loadAll]);
// Undo 履歴(直近20件)
const historyRef = useRef<HistoryEntry[]>([]);
const [canUndo, setCanUndo] = useState(false);
const pushHistory = useCallback((type: HistoryEntryType, before: HistoryEntry['before'], label?: string) => {
historyRef.current = [...historyRef.current, { type, before, time: Date.now(), label }].slice(-20);
setCanUndo(true);
}, []);
const saveTasks = useCallback((t: Task[]) => {
pushHistory('tasks', tasks, 'タスク');
setTasks(t);
saveToAPI('tasks', t);
}, [tasks, pushHistory]);
const saveProjects = useCallback((p: Project[]) => {
pushHistory('projects', projects, 'PJT');
setProjects(p);
saveToAPI('projects', p);
}, [projects, pushHistory]);
const saveBottomTasks = useCallback((bt: BottomTask[]) => {
pushHistory('bottom_tasks', bottomTasks, '定例タスク');
setBottomTasks(bt);
saveToAPI('bottom_tasks', bt);
}, [bottomTasks, pushHistory]);
const saveMembers = useCallback((m: TaskMember[]) => {
pushHistory('members', members, 'メンバー');
setMembers(m);
saveToAPI('members', m);
}, [members, pushHistory]);
const undo = useCallback((): { ok: boolean; label?: string } => {
const stack = historyRef.current;
if (stack.length === 0) return { ok: false };
const last = stack[stack.length - 1];
historyRef.current = stack.slice(0, -1);
setCanUndo(historyRef.current.length > 0);
switch (last.type) {
case 'tasks':
setTasks(last.before as Task[]);
saveToAPI('tasks', last.before);
break;
case 'projects':
setProjects(last.before as Project[]);
saveToAPI('projects', last.before);
break;
case 'bottom_tasks':
setBottomTasks(last.before as BottomTask[]);
saveToAPI('bottom_tasks', last.before);
break;
case 'members':
setMembers(last.before as TaskMember[]);
saveToAPI('members', last.before);
break;
}
return { ok: true, label: last.label };
}, []);
// Cmd+Z / Ctrl+Z でUndo(input/textarea内はブラウザ標準のundoに任せる)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key !== 'z' && e.key !== 'Z') return;
if (e.shiftKey) return; // Redoは未対応
const active = document.activeElement;
const tag = active?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || (active as HTMLElement)?.isContentEditable) return;
e.preventDefault();
const result = undo();
if (result.ok) {
showUndoToast(result.label);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [undo]);
const addAudit = useCallback((action: string, detail: string, taskId?: string) => {
setAuditLog(prev => {
const entry: AuditEntry = { id: uid(), user: currentUser, action, detail, taskId: taskId || '', time: now() };
const next = [entry, ...prev];
if (next.length > 5000) next.length = 5000;
saveToAPI('audit_log', next);
return next;
});
}, [currentUser]);
const scanSlack = useCallback(async (): Promise<SlackScanResult | null> => {
try {
const res = await fetch(`${API_BASE}/scan`);
const data = await res.json() as SlackScanResult;
if (data.addedCount && data.addedCount > 0) {
const fresh = await fetchFromAPI('tasks') as Task[];
if (fresh) setTasks(fresh);
}
return data;
} catch {
return null;
}
}, []);
const reload = useCallback(async () => {
await loadAll();
}, [loadAll]);
return (
<TasksContext.Provider value={{
tasks, projects, bottomTasks, members, auditLog, isLoading, currentUser,
saveTasks, saveProjects, saveBottomTasks, saveMembers, addAudit, scanSlack, reload,
undo, canUndo,
}}>
{children}
</TasksContext.Provider>
);
}
// ── 画面上部にUndo通知トーストを表示 ──
function showUndoToast(label?: string) {
if (typeof document === 'undefined') return;
const existing = document.getElementById('scale-undo-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'scale-undo-toast';
toast.textContent = label ? `↶ ${label}を元に戻しました` : '↶ 元に戻しました';
toast.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: rgba(100,133,184,0.95); color: white; padding: 10px 18px;
border-radius: 8px; font-size: 13px; font-weight: 500; z-index: 9999;
box-shadow: 0 4px 20px rgba(0,0,0,0.4); animation: scaleFadeIn .15s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1800);
}
export function useTasks(): TasksContextType {
const ctx = useContext(TasksContext);
if (!ctx) throw new Error('useTasks must be used within TasksProvider');
return ctx;
}
:LiFolder: ソースファイルのパス
/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/lib/tasks-api.tsx
:LiHandPointer: 使い方
対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。
:LiAlertCircle: 注意事項
- 依存パッケージを忘れず追加