作業ログ計測
: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: 注意事項
- 依存パッケージを忘れず追加