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

作業ログ計測

CATEGORY開発パターン TYPETypeScript Library EFFORT120〜300分 DIFFICULTY
PRIMARY CODE
ts · lib/work-log.ts
// Work log shared data layer
// localStorage-based store for 作業報告 (morning report, start/end session reports, night report)

export type ReportType = 'morning' | 'start' | 'end' | 'night';

export interface MorningReport {
  id: string;
  type: 'morning';
  userId: string;
  userName: string;
  date: string; // YYYY-MM-DD
  createdAt: string; // ISO
  todayPlan: string; // 今日やること(後方互換。現在は todayTasks を主に使用)
  todayTasks?: string[]; // 今日やるタスク(箇条書き)
  selectedRoutineIds?: string[]; // 選択した今日やる定例タスクのID
  baseTaskIds?: string[]; // SCALE Baseタスク管理シートから選択したタスクのID
  todayGoal?: string; // 今日の目標
  priorityTasks: string; // 優先タスク
  expectedHours: number; // 想定稼働時間
  mood: 'good' | 'normal' | 'tired'; // 体調
  note: string; // 報告/連絡/相談
}

export interface StartReport {
  id: string;
  type: 'start';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  sessionId: string; // links to WorkSession
  taskName: string; // 後方互換(tasks[0] と同じ)
  tasks?: string[]; // いまから取り組むタスク群(1行=1タスク)
  selectedRoutineIds?: string[]; // 選択した定例タスクID
  baseTaskIds?: string[]; // SCALE Baseタスク管理シートから選択したタスクのID
  projectName: string; // 使用停止(UIから削除)、型のみ残す
  expectedMinutes: number;
  goal?: string; // このセッションで達成したいこと(廃止。後方互換のため残置)
}

export interface EndReport {
  id: string;
  type: 'end';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  sessionId: string;
  progress: string; // 後方互換(extraWork と同じ)
  completed: boolean; // 後方互換
  completedTasks?: string[]; // 完了したタスク名のリスト
  completedRoutineIds?: string[]; // 完了した定例タスクID
  extraTasks?: string[]; // 開始時宣言以外で実施したタスク(夜日報に自動集計)
  extraWork?: string; // 後方互換(旧フリーテキスト)
  blockers: string; // 詰まったこと・課題
  nextAction?: string; // 次のアクション(廃止。フィールドは後方互換のため残置)
  actualMinutes: number;
  // この時間の目標(作業開始時の goal をコピー)+達成ステータス
  sessionGoal?: string;
  sessionGoalStatus?: 'achieved' | 'partial' | 'not-achieved';
  sessionGoalNote?: string; // 達成状況のメモ
  satisfaction?: number; // このセッションの満足度 1-5(質を可視化)
}

export interface NightReport {
  id: string;
  type: 'night';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  summary: string; // 後方互換(旧「今日やったことまとめ」。現在は dailyTasks / dailySummary を使用)
  dailyTasks?: string[]; // 今日やったタスク(作業報告から自動集計、編集可)
  dailySummary?: string; // 今日の総括/メモ
  learnings?: string; // 今日の学び
  achievements: string; // 後方互換(現UIでは非表示)
  challenges: string; // 後方互換(現UIでは improvements として表示)
  improvements?: string; // 伸びしろ / 改善点(challenges のリネーム)
  morningGoal?: string; // 朝日報で設定した今日の目標(コピー)
  goalAchievementStatus?: 'achieved' | 'partial' | 'not-achieved'; // 目標の達成ステータス
  goalAchievement?: string; // 今日の目標の達成状況テキスト
  tomorrowPlan: string; // 後方互換(現UIでは非表示)
  report?: string; // 報告/連絡/相談
  totalMinutes: number;
  satisfaction: number; // 1-5
}

export type AnyReport = MorningReport | StartReport | EndReport | NightReport;

// セッション中の「作業停止」区間(外出・別件対応で稼働を一時停止する用途)
export interface WorkPause {
  startedAt: string;          // ISO
  endedAt: string | null;     // ISO(停止中は null)
  reason?: string;            // 任意(例: '外出', '別件対応')
}

// A work session = from 作業開始報告 to 作業終了報告(1日に複数セッション可)
export interface WorkSession {
  id: string;
  userId: string;
  userName: string;
  date: string;
  startedAt: string; // ISO
  endedAt: string | null; // ISO, null = in progress
  taskName: string; // 後方互換(tasks[0] と同じ)
  tasks?: string[]; // このセッションで取り組むタスク群
  selectedRoutineIds?: string[]; // 選択した定例タスクID
  projectName: string; // 使用停止、型のみ残す
  minutes: number; // filled on end
  pauses?: WorkPause[]; // 作業停止区間。経過時間から差し引く
}

// Current work state per user
export interface WorkState {
  userId: string;
  date: string;
  morningReportDone: boolean;
  nightReportDone: boolean;
  currentSessionId: string | null; // running session id, null if paused/not started
  totalMinutesToday: number;
}

const KEY_REPORTS = 'scale-worklog-reports';
const KEY_SESSIONS = 'scale-worklog-sessions';
const KEY_STATE = 'scale-worklog-state';
const KEY_ACTIVE_PROGRESS = 'scale-worklog-active-progress'; // 作業中画面のタスク進捗

// 作業中画面で1タスクの進捗状態(3段階: 未着手→進行中→完了 + 時間計測)
export type ActiveTaskStatus = 'pending' | 'in-progress' | 'done';
export interface ActiveTaskProgress {
  taskText: string;            // タスク名
  source: 'morning' | 'start' | 'routine' | 'base' | 'ad-hoc';
  sourceId?: string;           // routineId / baseTaskId
  status: ActiveTaskStatus;    // 未着手 | 進行中 | 完了
  // このセッション(作業開始報告)で宣言されたタスクか
  // true: 今やる / false: 朝日報からの参考(このセッションでは後回し)
  isSessionPriority?: boolean; // undefined は互換のため true 扱い
  // 後方互換(旧フィールド)
  done?: boolean;              // 旧API用、新規書き込みは status を信頼
  doneAt?: string;             // ISO 完了時刻
  timerStartedAt?: string | null; // ISO 計測中なら値あり、止めたらnull
  accumulatedMs: number;       // 累積時間(ms)
  googleEventId?: string;      // Googleカレンダー連動時のイベントID
  // タイマー中断履歴(停止ボタン押下時に1区間ぶん追加)
  interruptions?: { startedAt: string; endedAt: string; durationMs: number }[];
}

// ─── Active progress (per-session) ───
// キー: scale-worklog-active-progress → { [sessionId]: ActiveTaskProgress[] }
export function loadActiveProgressMap(): Record<string, ActiveTaskProgress[]> {
  if (typeof window === 'undefined') return {};
  try { const v = localStorage.getItem(KEY_ACTIVE_PROGRESS); return v ? JSON.parse(v) : {}; } catch { return {}; }
}
export function saveActiveProgressMap(map: Record<string, ActiveTaskProgress[]>) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_ACTIVE_PROGRESS, JSON.stringify(map));
}
export function getActiveProgress(sessionId: string): ActiveTaskProgress[] {
  const map = loadActiveProgressMap();
  return map[sessionId] || [];
}
export function setActiveProgress(sessionId: string, progress: ActiveTaskProgress[]) {
  const map = loadActiveProgressMap();
  map[sessionId] = progress;
  saveActiveProgressMap(map);
}

// ─── 累計時間(複数セッションを跨いで集計) ───
// 長期タスク(5時間タスクを今日1時間、明日2時間 等)の総稼働時間を取得する用。
// 進行中の timerStartedAt があるセッションは現在時刻まで含めて計算。
export interface AccumulatedSummary {
  totalMs: number;          // すべての完了/中断分 + 進行中の現在進行ぶん
  doneSessionsMs: number;   // 過去セッション分の合計(参考)
  isCurrentlyRunning: boolean; // どこかのセッションで計測中か
  sessionsCount: number;    // このタスクが含まれていたセッション数
}
export function getAccumulatedMsForTask(opts: {
  taskName?: string;
  taskId?: string; // sourceId と一致する base/routine の id
  excludeSessionId?: string; // 現セッション分を除外したい場合
  userId?: string;            // ユーザー絞り込み(未指定なら全件)
}): AccumulatedSummary {
  const { taskName, taskId, excludeSessionId, userId } = opts;
  if (!taskName && !taskId) {
    return { totalMs: 0, doneSessionsMs: 0, isCurrentlyRunning: false, sessionsCount: 0 };
  }
  const sessions = loadSessions();
  const progressMap = loadActiveProgressMap();
  let totalMs = 0;
  let doneSessionsMs = 0;
  let isCurrentlyRunning = false;
  let sessionsCount = 0;
  const now = Date.now();

  for (const s of sessions) {
    if (excludeSessionId && s.id === excludeSessionId) continue;
    if (userId && s.userId !== userId) continue;
    const prog = progressMap[s.id] || [];
    for (const p of prog) {
      // ID 一致 or 名前一致で同タスクと判定
      const matchById = taskId && p.sourceId === taskId;
      const matchByName = taskName && p.taskText === taskName;
      if (!matchById && !matchByName) continue;
      sessionsCount++;
      // 累積分(既に止めた区間)
      totalMs += p.accumulatedMs || 0;
      doneSessionsMs += p.accumulatedMs || 0;
      // 現在計測中なら startedAt から now までを加算
      if (p.timerStartedAt) {
        const running = Math.max(0, now - new Date(p.timerStartedAt).getTime());
        totalMs += running;
        isCurrentlyRunning = true;
      }
    }
  }
  return { totalMs, doneSessionsMs, isCurrentlyRunning, sessionsCount };
}

// 残時間計算: 想定時間 - 累積時間(負にはならない)
// estimateMin が無い時は undefined を返す
export function remainingMinutesForTask(opts: {
  estimateMinutes?: number;
  taskName?: string;
  taskId?: string;
  excludeSessionId?: string;
  userId?: string;
}): number | undefined {
  const { estimateMinutes } = opts;
  if (!estimateMinutes || estimateMinutes <= 0) return undefined;
  const acc = getAccumulatedMsForTask(opts);
  const accMin = Math.round(acc.totalMs / 60000);
  return Math.max(0, estimateMinutes - accMin);
}

// ─── Reports ───
export function loadReports(): AnyReport[] {
  if (typeof window === 'undefined') return [];
  try { const v = localStorage.getItem(KEY_REPORTS); return v ? JSON.parse(v) : []; } catch { return []; }
}
export function saveReports(data: AnyReport[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_REPORTS, JSON.stringify(data));
}
export function addReport(r: AnyReport) {
  const all = loadReports();
  saveReports([r, ...all]);
}
// 今日の特定typeのレポートを削除(作業再開などで夜日報を取り消す用途)
export function deleteTodayReport(userId: string, type: ReportType) {
  const today = getTodayDate();
  const all = loadReports();
  const next = all.filter(r => !(r.type === type && r.userId === userId && r.date === today));
  saveReports(next);
}
// 同じ user/date/type のレポートがあれば置き換え、無ければ追加
export function upsertReport(r: AnyReport) {
  const all = loadReports();
  // morning/night は 1日1件に統一(同type同日同userは置き換え)
  const idx = all.findIndex(x => x.userId === r.userId && x.date === r.date && x.type === r.type);
  if (idx >= 0) {
    const next = [...all];
    next[idx] = r;
    saveReports(next);
  } else {
    saveReports([r, ...all]);
  }
}

// ─── Sessions ───
export function loadSessions(): WorkSession[] {
  if (typeof window === 'undefined') return [];
  try { const v = localStorage.getItem(KEY_SESSIONS); return v ? JSON.parse(v) : []; } catch { return []; }
}
export function saveSessions(data: WorkSession[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_SESSIONS, JSON.stringify(data));
}

// ─── State (per user, keyed by userId) ───
export function loadStateMap(): Record<string, WorkState> {
  if (typeof window === 'undefined') return {};
  try { const v = localStorage.getItem(KEY_STATE); return v ? JSON.parse(v) : {}; } catch { return {}; }
}
export function saveStateMap(map: Record<string, WorkState>) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_STATE, JSON.stringify(map));
}
export function getUserState(userId: string): WorkState {
  const map = loadStateMap();
  const today = getTodayDate();
  let state = map[userId];
  if (!state || state.date !== today) {
    state = {
      userId,
      date: today,
      morningReportDone: false,
      nightReportDone: false,
      currentSessionId: null,
      totalMinutesToday: 0,
    };
    map[userId] = state;
    saveStateMap(map);
  }
  return state;
}
export function updateUserState(userId: string, patch: Partial<WorkState>) {
  const map = loadStateMap();
  const current = getUserState(userId);
  map[userId] = { ...current, ...patch };
  saveStateMap(map);
  return map[userId];
}

// ─── Utilities ───
// 業務日(business day)の境界は朝3時。
// 0:00-2:59 JSTは前日扱い、3:00以降は当日扱い。
// 深夜まで作業しても日付が変わらず、朝3時以降の初回操作で「新しい1日」になる。
export function getTodayDate(): string {
  const d = new Date();
  if (d.getHours() < 3) {
    d.setDate(d.getDate() - 1);
  }
  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 getYesterdayDate(): string {
  const todayStr = getTodayDate();
  const [y, m, d] = todayStr.split('-').map(Number);
  const dt = new Date(y, m - 1, d - 1);
  const yy = dt.getFullYear();
  const mm = String(dt.getMonth() + 1).padStart(2, '0');
  const dd = String(dt.getDate()).padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}
// ISO 文字列(startedAt / createdAt)からローカル(JST)日付を計算
export function jstDateFromISO(iso?: string | null): string {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d.getTime())) return '';
  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}`;
}
// 旧UTCベースで保存されたデータの date と targetDate が JST 換算で一致するかも見て判定
export function matchesWorkDate(rec: { date: string; startedAt?: string; createdAt?: string }, targetDate: string): boolean {
  if (rec.date === targetDate) return true;
  const isoDate = jstDateFromISO(rec.startedAt || rec.createdAt);
  return isoDate === targetDate;
}
export function nowISO(): string {
  return new Date().toISOString();
}
export function genId(prefix: string): string {
  return prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
}
export function fmtDuration(minutes: number): string {
  if (minutes < 60) return `${minutes}分`;
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return m === 0 ? `${h}時間` : `${h}時間${m}分`;
}
export function fmtTime(iso: string): string {
  return new Date(iso).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
}
export function diffMinutes(startISO: string, endISO: string): number {
  return Math.max(0, Math.round((new Date(endISO).getTime() - new Date(startISO).getTime()) / 60000));
}

// セッションの停止累計(ms)。停止中(endedAt=null)は now との差を計上
export function pausedDurationMs(session: WorkSession, atIso?: string): number {
  const at = atIso ? new Date(atIso).getTime() : Date.now();
  const pauses = session.pauses || [];
  let total = 0;
  for (const p of pauses) {
    const start = new Date(p.startedAt).getTime();
    const end = p.endedAt ? new Date(p.endedAt).getTime() : at;
    total += Math.max(0, end - start);
  }
  return total;
}

// セッションが現在停止中かどうか
export function isSessionPaused(session: WorkSession): boolean {
  return !!(session.pauses || []).find(p => !p.endedAt);
}

// セッションの「実稼働時間(分)」 = (end - start) - 停止累計
export function netSessionMinutes(session: WorkSession, endIso?: string): number {
  const start = new Date(session.startedAt).getTime();
  const end = endIso ? new Date(endIso).getTime() : (session.endedAt ? new Date(session.endedAt).getTime() : Date.now());
  const gross = Math.max(0, end - start);
  const paused = pausedDurationMs(session, endIso);
  return Math.max(0, Math.round((gross - paused) / 60000));
}

// Seed demo data (only first run)
export function seedIfEmpty() {
  // ダミーデータは削除済み(本番運用開始)
  return;
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 作業ログ
  • タイムトラッキング

作業ログ計測

:LiTarget: 用途

タスクの開始/終了を記録して実働時間を集計するロジック。

:LiSparkle: 特徴

  • 開始/終了記録
  • 集計
  • カレンダー連携

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

:LiInfo: lib/work-log.ts の中身そのもの。コピペ即可。

// Work log shared data layer
// localStorage-based store for 作業報告 (morning report, start/end session reports, night report)

export type ReportType = 'morning' | 'start' | 'end' | 'night';

export interface MorningReport {
  id: string;
  type: 'morning';
  userId: string;
  userName: string;
  date: string; // YYYY-MM-DD
  createdAt: string; // ISO
  todayPlan: string; // 今日やること(後方互換。現在は todayTasks を主に使用)
  todayTasks?: string[]; // 今日やるタスク(箇条書き)
  selectedRoutineIds?: string[]; // 選択した今日やる定例タスクのID
  baseTaskIds?: string[]; // SCALE Baseタスク管理シートから選択したタスクのID
  todayGoal?: string; // 今日の目標
  priorityTasks: string; // 優先タスク
  expectedHours: number; // 想定稼働時間
  mood: 'good' | 'normal' | 'tired'; // 体調
  note: string; // 報告/連絡/相談
}

export interface StartReport {
  id: string;
  type: 'start';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  sessionId: string; // links to WorkSession
  taskName: string; // 後方互換(tasks[0] と同じ)
  tasks?: string[]; // いまから取り組むタスク群(1行=1タスク)
  selectedRoutineIds?: string[]; // 選択した定例タスクID
  baseTaskIds?: string[]; // SCALE Baseタスク管理シートから選択したタスクのID
  projectName: string; // 使用停止(UIから削除)、型のみ残す
  expectedMinutes: number;
  goal?: string; // このセッションで達成したいこと(廃止。後方互換のため残置)
}

export interface EndReport {
  id: string;
  type: 'end';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  sessionId: string;
  progress: string; // 後方互換(extraWork と同じ)
  completed: boolean; // 後方互換
  completedTasks?: string[]; // 完了したタスク名のリスト
  completedRoutineIds?: string[]; // 完了した定例タスクID
  extraTasks?: string[]; // 開始時宣言以外で実施したタスク(夜日報に自動集計)
  extraWork?: string; // 後方互換(旧フリーテキスト)
  blockers: string; // 詰まったこと・課題
  nextAction?: string; // 次のアクション(廃止。フィールドは後方互換のため残置)
  actualMinutes: number;
  // この時間の目標(作業開始時の goal をコピー)+達成ステータス
  sessionGoal?: string;
  sessionGoalStatus?: 'achieved' | 'partial' | 'not-achieved';
  sessionGoalNote?: string; // 達成状況のメモ
  satisfaction?: number; // このセッションの満足度 1-5(質を可視化)
}

export interface NightReport {
  id: string;
  type: 'night';
  userId: string;
  userName: string;
  date: string;
  createdAt: string;
  summary: string; // 後方互換(旧「今日やったことまとめ」。現在は dailyTasks / dailySummary を使用)
  dailyTasks?: string[]; // 今日やったタスク(作業報告から自動集計、編集可)
  dailySummary?: string; // 今日の総括/メモ
  learnings?: string; // 今日の学び
  achievements: string; // 後方互換(現UIでは非表示)
  challenges: string; // 後方互換(現UIでは improvements として表示)
  improvements?: string; // 伸びしろ / 改善点(challenges のリネーム)
  morningGoal?: string; // 朝日報で設定した今日の目標(コピー)
  goalAchievementStatus?: 'achieved' | 'partial' | 'not-achieved'; // 目標の達成ステータス
  goalAchievement?: string; // 今日の目標の達成状況テキスト
  tomorrowPlan: string; // 後方互換(現UIでは非表示)
  report?: string; // 報告/連絡/相談
  totalMinutes: number;
  satisfaction: number; // 1-5
}

export type AnyReport = MorningReport | StartReport | EndReport | NightReport;

// セッション中の「作業停止」区間(外出・別件対応で稼働を一時停止する用途)
export interface WorkPause {
  startedAt: string;          // ISO
  endedAt: string | null;     // ISO(停止中は null)
  reason?: string;            // 任意(例: '外出', '別件対応')
}

// A work session = from 作業開始報告 to 作業終了報告(1日に複数セッション可)
export interface WorkSession {
  id: string;
  userId: string;
  userName: string;
  date: string;
  startedAt: string; // ISO
  endedAt: string | null; // ISO, null = in progress
  taskName: string; // 後方互換(tasks[0] と同じ)
  tasks?: string[]; // このセッションで取り組むタスク群
  selectedRoutineIds?: string[]; // 選択した定例タスクID
  projectName: string; // 使用停止、型のみ残す
  minutes: number; // filled on end
  pauses?: WorkPause[]; // 作業停止区間。経過時間から差し引く
}

// Current work state per user
export interface WorkState {
  userId: string;
  date: string;
  morningReportDone: boolean;
  nightReportDone: boolean;
  currentSessionId: string | null; // running session id, null if paused/not started
  totalMinutesToday: number;
}

const KEY_REPORTS = 'scale-worklog-reports';
const KEY_SESSIONS = 'scale-worklog-sessions';
const KEY_STATE = 'scale-worklog-state';
const KEY_ACTIVE_PROGRESS = 'scale-worklog-active-progress'; // 作業中画面のタスク進捗

// 作業中画面で1タスクの進捗状態(3段階: 未着手→進行中→完了 + 時間計測)
export type ActiveTaskStatus = 'pending' | 'in-progress' | 'done';
export interface ActiveTaskProgress {
  taskText: string;            // タスク名
  source: 'morning' | 'start' | 'routine' | 'base' | 'ad-hoc';
  sourceId?: string;           // routineId / baseTaskId
  status: ActiveTaskStatus;    // 未着手 | 進行中 | 完了
  // このセッション(作業開始報告)で宣言されたタスクか
  // true: 今やる / false: 朝日報からの参考(このセッションでは後回し)
  isSessionPriority?: boolean; // undefined は互換のため true 扱い
  // 後方互換(旧フィールド)
  done?: boolean;              // 旧API用、新規書き込みは status を信頼
  doneAt?: string;             // ISO 完了時刻
  timerStartedAt?: string | null; // ISO 計測中なら値あり、止めたらnull
  accumulatedMs: number;       // 累積時間(ms)
  googleEventId?: string;      // Googleカレンダー連動時のイベントID
  // タイマー中断履歴(停止ボタン押下時に1区間ぶん追加)
  interruptions?: { startedAt: string; endedAt: string; durationMs: number }[];
}

// ─── Active progress (per-session) ───
// キー: scale-worklog-active-progress → { [sessionId]: ActiveTaskProgress[] }
export function loadActiveProgressMap(): Record<string, ActiveTaskProgress[]> {
  if (typeof window === 'undefined') return {};
  try { const v = localStorage.getItem(KEY_ACTIVE_PROGRESS); return v ? JSON.parse(v) : {}; } catch { return {}; }
}
export function saveActiveProgressMap(map: Record<string, ActiveTaskProgress[]>) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_ACTIVE_PROGRESS, JSON.stringify(map));
}
export function getActiveProgress(sessionId: string): ActiveTaskProgress[] {
  const map = loadActiveProgressMap();
  return map[sessionId] || [];
}
export function setActiveProgress(sessionId: string, progress: ActiveTaskProgress[]) {
  const map = loadActiveProgressMap();
  map[sessionId] = progress;
  saveActiveProgressMap(map);
}

// ─── 累計時間(複数セッションを跨いで集計) ───
// 長期タスク(5時間タスクを今日1時間、明日2時間 等)の総稼働時間を取得する用。
// 進行中の timerStartedAt があるセッションは現在時刻まで含めて計算。
export interface AccumulatedSummary {
  totalMs: number;          // すべての完了/中断分 + 進行中の現在進行ぶん
  doneSessionsMs: number;   // 過去セッション分の合計(参考)
  isCurrentlyRunning: boolean; // どこかのセッションで計測中か
  sessionsCount: number;    // このタスクが含まれていたセッション数
}
export function getAccumulatedMsForTask(opts: {
  taskName?: string;
  taskId?: string; // sourceId と一致する base/routine の id
  excludeSessionId?: string; // 現セッション分を除外したい場合
  userId?: string;            // ユーザー絞り込み(未指定なら全件)
}): AccumulatedSummary {
  const { taskName, taskId, excludeSessionId, userId } = opts;
  if (!taskName && !taskId) {
    return { totalMs: 0, doneSessionsMs: 0, isCurrentlyRunning: false, sessionsCount: 0 };
  }
  const sessions = loadSessions();
  const progressMap = loadActiveProgressMap();
  let totalMs = 0;
  let doneSessionsMs = 0;
  let isCurrentlyRunning = false;
  let sessionsCount = 0;
  const now = Date.now();

  for (const s of sessions) {
    if (excludeSessionId && s.id === excludeSessionId) continue;
    if (userId && s.userId !== userId) continue;
    const prog = progressMap[s.id] || [];
    for (const p of prog) {
      // ID 一致 or 名前一致で同タスクと判定
      const matchById = taskId && p.sourceId === taskId;
      const matchByName = taskName && p.taskText === taskName;
      if (!matchById && !matchByName) continue;
      sessionsCount++;
      // 累積分(既に止めた区間)
      totalMs += p.accumulatedMs || 0;
      doneSessionsMs += p.accumulatedMs || 0;
      // 現在計測中なら startedAt から now までを加算
      if (p.timerStartedAt) {
        const running = Math.max(0, now - new Date(p.timerStartedAt).getTime());
        totalMs += running;
        isCurrentlyRunning = true;
      }
    }
  }
  return { totalMs, doneSessionsMs, isCurrentlyRunning, sessionsCount };
}

// 残時間計算: 想定時間 - 累積時間(負にはならない)
// estimateMin が無い時は undefined を返す
export function remainingMinutesForTask(opts: {
  estimateMinutes?: number;
  taskName?: string;
  taskId?: string;
  excludeSessionId?: string;
  userId?: string;
}): number | undefined {
  const { estimateMinutes } = opts;
  if (!estimateMinutes || estimateMinutes <= 0) return undefined;
  const acc = getAccumulatedMsForTask(opts);
  const accMin = Math.round(acc.totalMs / 60000);
  return Math.max(0, estimateMinutes - accMin);
}

// ─── Reports ───
export function loadReports(): AnyReport[] {
  if (typeof window === 'undefined') return [];
  try { const v = localStorage.getItem(KEY_REPORTS); return v ? JSON.parse(v) : []; } catch { return []; }
}
export function saveReports(data: AnyReport[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_REPORTS, JSON.stringify(data));
}
export function addReport(r: AnyReport) {
  const all = loadReports();
  saveReports([r, ...all]);
}
// 今日の特定typeのレポートを削除(作業再開などで夜日報を取り消す用途)
export function deleteTodayReport(userId: string, type: ReportType) {
  const today = getTodayDate();
  const all = loadReports();
  const next = all.filter(r => !(r.type === type && r.userId === userId && r.date === today));
  saveReports(next);
}
// 同じ user/date/type のレポートがあれば置き換え、無ければ追加
export function upsertReport(r: AnyReport) {
  const all = loadReports();
  // morning/night は 1日1件に統一(同type同日同userは置き換え)
  const idx = all.findIndex(x => x.userId === r.userId && x.date === r.date && x.type === r.type);
  if (idx >= 0) {
    const next = [...all];
    next[idx] = r;
    saveReports(next);
  } else {
    saveReports([r, ...all]);
  }
}

// ─── Sessions ───
export function loadSessions(): WorkSession[] {
  if (typeof window === 'undefined') return [];
  try { const v = localStorage.getItem(KEY_SESSIONS); return v ? JSON.parse(v) : []; } catch { return []; }
}
export function saveSessions(data: WorkSession[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_SESSIONS, JSON.stringify(data));
}

// ─── State (per user, keyed by userId) ───
export function loadStateMap(): Record<string, WorkState> {
  if (typeof window === 'undefined') return {};
  try { const v = localStorage.getItem(KEY_STATE); return v ? JSON.parse(v) : {}; } catch { return {}; }
}
export function saveStateMap(map: Record<string, WorkState>) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_STATE, JSON.stringify(map));
}
export function getUserState(userId: string): WorkState {
  const map = loadStateMap();
  const today = getTodayDate();
  let state = map[userId];
  if (!state || state.date !== today) {
    state = {
      userId,
      date: today,
      morningReportDone: false,
      nightReportDone: false,
      currentSessionId: null,
      totalMinutesToday: 0,
    };
    map[userId] = state;
    saveStateMap(map);
  }
  return state;
}
export function updateUserState(userId: string, patch: Partial<WorkState>) {
  const map = loadStateMap();
  const current = getUserState(userId);
  map[userId] = { ...current, ...patch };
  saveStateMap(map);
  return map[userId];
}

// ─── Utilities ───
// 業務日(business day)の境界は朝3時。
// 0:00-2:59 JSTは前日扱い、3:00以降は当日扱い。
// 深夜まで作業しても日付が変わらず、朝3時以降の初回操作で「新しい1日」になる。
export function getTodayDate(): string {
  const d = new Date();
  if (d.getHours() < 3) {
    d.setDate(d.getDate() - 1);
  }
  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 getYesterdayDate(): string {
  const todayStr = getTodayDate();
  const [y, m, d] = todayStr.split('-').map(Number);
  const dt = new Date(y, m - 1, d - 1);
  const yy = dt.getFullYear();
  const mm = String(dt.getMonth() + 1).padStart(2, '0');
  const dd = String(dt.getDate()).padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}
// ISO 文字列(startedAt / createdAt)からローカル(JST)日付を計算
export function jstDateFromISO(iso?: string | null): string {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d.getTime())) return '';
  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}`;
}
// 旧UTCベースで保存されたデータの date と targetDate が JST 換算で一致するかも見て判定
export function matchesWorkDate(rec: { date: string; startedAt?: string; createdAt?: string }, targetDate: string): boolean {
  if (rec.date === targetDate) return true;
  const isoDate = jstDateFromISO(rec.startedAt || rec.createdAt);
  return isoDate === targetDate;
}
export function nowISO(): string {
  return new Date().toISOString();
}
export function genId(prefix: string): string {
  return prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
}
export function fmtDuration(minutes: number): string {
  if (minutes < 60) return `${minutes}分`;
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return m === 0 ? `${h}時間` : `${h}時間${m}分`;
}
export function fmtTime(iso: string): string {
  return new Date(iso).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
}
export function diffMinutes(startISO: string, endISO: string): number {
  return Math.max(0, Math.round((new Date(endISO).getTime() - new Date(startISO).getTime()) / 60000));
}

// セッションの停止累計(ms)。停止中(endedAt=null)は now との差を計上
export function pausedDurationMs(session: WorkSession, atIso?: string): number {
  const at = atIso ? new Date(atIso).getTime() : Date.now();
  const pauses = session.pauses || [];
  let total = 0;
  for (const p of pauses) {
    const start = new Date(p.startedAt).getTime();
    const end = p.endedAt ? new Date(p.endedAt).getTime() : at;
    total += Math.max(0, end - start);
  }
  return total;
}

// セッションが現在停止中かどうか
export function isSessionPaused(session: WorkSession): boolean {
  return !!(session.pauses || []).find(p => !p.endedAt);
}

// セッションの「実稼働時間(分)」 = (end - start) - 停止累計
export function netSessionMinutes(session: WorkSession, endIso?: string): number {
  const start = new Date(session.startedAt).getTime();
  const end = endIso ? new Date(endIso).getTime() : (session.endedAt ? new Date(session.endedAt).getTime() : Date.now());
  const gross = Math.max(0, end - start);
  const paused = pausedDurationMs(session, endIso);
  return Math.max(0, Math.round((gross - paused) / 60000));
}

// Seed demo data (only first run)
export function seedIfEmpty() {
  // ダミーデータは削除済み(本番運用開始)
  return;
}

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

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

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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