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

スケジュールデータ管理

CATEGORY開発パターン TYPETypeScript Library EFFORT90〜240分 DIFFICULTY
PRIMARY CODE
ts · lib/scheduling-data.ts
// 日程調整ビルダー(apo-sukeライクな設計)
// コンセプト: URLを送らず、候補日をテキストで生成してコピペ送信

export interface SchedulingSettings {
  meetingMin: number; // 会議時間(分)
  daysAhead: number;  // 何日先まで抽出
  startDayOffset: number; // 開始日オフセット(0=今日、1=明日から)
  workStart: string;  // '09:00'
  workEnd: string;    // '19:00'
  weekdays: number[]; // [1,2,3,4,5] (日=0, 月=1 ... 土=6)
  bufferBefore: number; // 前後バッファ(分)
  bufferAfter: number;
  maxCandidates: number;
  slotInterval: number; // 候補の刻み(分)例: 30
  fmt: 'simple' | 'polite' | 'business';
  lunchStart?: string; // '12:00'
  lunchEnd?: string;   // '13:00'
  excludeLunch: boolean;
}

export interface BusySlot {
  id: string;
  date: string; // YYYY-MM-DD
  start: string; // HH:MM
  end: string;   // HH:MM
  label?: string;
}

export interface Candidate {
  date: string; // YYYY-MM-DD
  startMin: number;
  endMin: number;
}

export const DEFAULT_SETTINGS: SchedulingSettings = {
  meetingMin: 30,
  daysAhead: 10,
  startDayOffset: 1,
  workStart: '09:00',
  workEnd: '19:00',
  weekdays: [1, 2, 3, 4, 5],
  bufferBefore: 15,
  bufferAfter: 15,
  maxCandidates: 5,
  slotInterval: 30,
  fmt: 'polite',
  lunchStart: '12:00',
  lunchEnd: '13:00',
  excludeLunch: true,
};

export const DAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];

export function hhmmToMin(hhmm: string): number {
  const [h, m] = hhmm.split(':').map(Number);
  return h * 60 + (m || 0);
}

export function minToHHMM(min: number): string {
  const h = Math.floor(min / 60);
  const m = min % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

export 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 generateCandidates(settings: SchedulingSettings, busy: BusySlot[]): Candidate[] {
  const candidates: Candidate[] = [];
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  for (let day = settings.startDayOffset; day < settings.startDayOffset + settings.daysAhead; day++) {
    const date = new Date(today);
    date.setDate(today.getDate() + day);
    const dow = date.getDay();
    if (!settings.weekdays.includes(dow)) continue;

    const dateStr = ymd(date);
    const dayBusy = busy.filter(b => b.date === dateStr);

    const startMin = hhmmToMin(settings.workStart);
    const endMin = hhmmToMin(settings.workEnd);
    const duration = settings.meetingMin;
    const lunchS = settings.lunchStart ? hhmmToMin(settings.lunchStart) : null;
    const lunchE = settings.lunchEnd ? hhmmToMin(settings.lunchEnd) : null;

    for (let t = startMin; t + duration <= endMin; t += settings.slotInterval) {
      const slotStart = t;
      const slotEnd = t + duration;

      // ランチタイム除外
      if (settings.excludeLunch && lunchS !== null && lunchE !== null) {
        if (slotStart < lunchE && slotEnd > lunchS) continue;
      }

      // 既存予定との重複チェック(バッファ込み)
      const hasConflict = dayBusy.some(b => {
        const bs = hhmmToMin(b.start) - settings.bufferBefore;
        const be = hhmmToMin(b.end) + settings.bufferAfter;
        return slotStart < be && slotEnd > bs;
      });
      if (hasConflict) continue;

      candidates.push({ date: dateStr, startMin: slotStart, endMin: slotEnd });
      if (candidates.length >= settings.maxCandidates) return candidates;
    }
  }
  return candidates;
}

export function formatCandidate(c: Candidate): string {
  const d = new Date(c.date + 'T00:00:00');
  const dow = DAYS_JA[d.getDay()];
  return `${d.getMonth() + 1}/${d.getDate()}(${dow}) ${minToHHMM(c.startMin)}-${minToHHMM(c.endMin)}`;
}

export function formatCandidatesText(candidates: Candidate[], fmt: SchedulingSettings['fmt']): string {
  if (candidates.length === 0) return '候補日が見つかりませんでした。設定を調整してください。';

  const lines = candidates.map(c => `・${formatCandidate(c)}`);

  switch (fmt) {
    case 'polite':
      return `お世話になっております。\n下記の日程でお打ち合わせをご調整いただけますと幸いです。\n\n${lines.join('\n')}\n\n上記でご都合の良い時間帯がございましたら、お知らせください。\nよろしくお願いいたします。`;
    case 'business':
      return `打ち合わせ候補日時、下記の中からご都合の良い時間をお知らせください。\n\n${lines.join('\n')}`;
    case 'simple':
    default:
      return `候補日時:\n${lines.join('\n')}`;
  }
}

const KEY_SETTINGS = 'scale-scheduling-settings';
const KEY_BUSY = 'scale-scheduling-busy';

export function loadSettings(): SchedulingSettings {
  if (typeof window === 'undefined') return DEFAULT_SETTINGS;
  try {
    const v = localStorage.getItem(KEY_SETTINGS);
    if (!v) return DEFAULT_SETTINGS;
    return { ...DEFAULT_SETTINGS, ...JSON.parse(v) };
  } catch {
    return DEFAULT_SETTINGS;
  }
}

export function saveSettings(s: SchedulingSettings) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_SETTINGS, JSON.stringify(s));
}

export function loadBusy(): BusySlot[] {
  if (typeof window === 'undefined') return [];
  try {
    const v = localStorage.getItem(KEY_BUSY);
    if (!v) return [];
    return JSON.parse(v);
  } catch {
    return [];
  }
}

export function saveBusy(b: BusySlot[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_BUSY, JSON.stringify(b));
}

export function genId(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 日程調整ツール
  • 面接予約
  • コンサル予約

スケジュールデータ管理

:LiTarget: 用途

ミーティング日程調整・候補日提示・確定管理のロジック。

:LiSparkle: 特徴

  • 候補日生成
  • 確定管理
  • 招待状送信
  • リマインダー

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

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

// 日程調整ビルダー(apo-sukeライクな設計)
// コンセプト: URLを送らず、候補日をテキストで生成してコピペ送信

export interface SchedulingSettings {
  meetingMin: number; // 会議時間(分)
  daysAhead: number;  // 何日先まで抽出
  startDayOffset: number; // 開始日オフセット(0=今日、1=明日から)
  workStart: string;  // '09:00'
  workEnd: string;    // '19:00'
  weekdays: number[]; // [1,2,3,4,5] (日=0, 月=1 ... 土=6)
  bufferBefore: number; // 前後バッファ(分)
  bufferAfter: number;
  maxCandidates: number;
  slotInterval: number; // 候補の刻み(分)例: 30
  fmt: 'simple' | 'polite' | 'business';
  lunchStart?: string; // '12:00'
  lunchEnd?: string;   // '13:00'
  excludeLunch: boolean;
}

export interface BusySlot {
  id: string;
  date: string; // YYYY-MM-DD
  start: string; // HH:MM
  end: string;   // HH:MM
  label?: string;
}

export interface Candidate {
  date: string; // YYYY-MM-DD
  startMin: number;
  endMin: number;
}

export const DEFAULT_SETTINGS: SchedulingSettings = {
  meetingMin: 30,
  daysAhead: 10,
  startDayOffset: 1,
  workStart: '09:00',
  workEnd: '19:00',
  weekdays: [1, 2, 3, 4, 5],
  bufferBefore: 15,
  bufferAfter: 15,
  maxCandidates: 5,
  slotInterval: 30,
  fmt: 'polite',
  lunchStart: '12:00',
  lunchEnd: '13:00',
  excludeLunch: true,
};

export const DAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];

export function hhmmToMin(hhmm: string): number {
  const [h, m] = hhmm.split(':').map(Number);
  return h * 60 + (m || 0);
}

export function minToHHMM(min: number): string {
  const h = Math.floor(min / 60);
  const m = min % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

export 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 generateCandidates(settings: SchedulingSettings, busy: BusySlot[]): Candidate[] {
  const candidates: Candidate[] = [];
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  for (let day = settings.startDayOffset; day < settings.startDayOffset + settings.daysAhead; day++) {
    const date = new Date(today);
    date.setDate(today.getDate() + day);
    const dow = date.getDay();
    if (!settings.weekdays.includes(dow)) continue;

    const dateStr = ymd(date);
    const dayBusy = busy.filter(b => b.date === dateStr);

    const startMin = hhmmToMin(settings.workStart);
    const endMin = hhmmToMin(settings.workEnd);
    const duration = settings.meetingMin;
    const lunchS = settings.lunchStart ? hhmmToMin(settings.lunchStart) : null;
    const lunchE = settings.lunchEnd ? hhmmToMin(settings.lunchEnd) : null;

    for (let t = startMin; t + duration <= endMin; t += settings.slotInterval) {
      const slotStart = t;
      const slotEnd = t + duration;

      // ランチタイム除外
      if (settings.excludeLunch && lunchS !== null && lunchE !== null) {
        if (slotStart < lunchE && slotEnd > lunchS) continue;
      }

      // 既存予定との重複チェック(バッファ込み)
      const hasConflict = dayBusy.some(b => {
        const bs = hhmmToMin(b.start) - settings.bufferBefore;
        const be = hhmmToMin(b.end) + settings.bufferAfter;
        return slotStart < be && slotEnd > bs;
      });
      if (hasConflict) continue;

      candidates.push({ date: dateStr, startMin: slotStart, endMin: slotEnd });
      if (candidates.length >= settings.maxCandidates) return candidates;
    }
  }
  return candidates;
}

export function formatCandidate(c: Candidate): string {
  const d = new Date(c.date + 'T00:00:00');
  const dow = DAYS_JA[d.getDay()];
  return `${d.getMonth() + 1}/${d.getDate()}(${dow}) ${minToHHMM(c.startMin)}-${minToHHMM(c.endMin)}`;
}

export function formatCandidatesText(candidates: Candidate[], fmt: SchedulingSettings['fmt']): string {
  if (candidates.length === 0) return '候補日が見つかりませんでした。設定を調整してください。';

  const lines = candidates.map(c => `・${formatCandidate(c)}`);

  switch (fmt) {
    case 'polite':
      return `お世話になっております。\n下記の日程でお打ち合わせをご調整いただけますと幸いです。\n\n${lines.join('\n')}\n\n上記でご都合の良い時間帯がございましたら、お知らせください。\nよろしくお願いいたします。`;
    case 'business':
      return `打ち合わせ候補日時、下記の中からご都合の良い時間をお知らせください。\n\n${lines.join('\n')}`;
    case 'simple':
    default:
      return `候補日時:\n${lines.join('\n')}`;
  }
}

const KEY_SETTINGS = 'scale-scheduling-settings';
const KEY_BUSY = 'scale-scheduling-busy';

export function loadSettings(): SchedulingSettings {
  if (typeof window === 'undefined') return DEFAULT_SETTINGS;
  try {
    const v = localStorage.getItem(KEY_SETTINGS);
    if (!v) return DEFAULT_SETTINGS;
    return { ...DEFAULT_SETTINGS, ...JSON.parse(v) };
  } catch {
    return DEFAULT_SETTINGS;
  }
}

export function saveSettings(s: SchedulingSettings) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_SETTINGS, JSON.stringify(s));
}

export function loadBusy(): BusySlot[] {
  if (typeof window === 'undefined') return [];
  try {
    const v = localStorage.getItem(KEY_BUSY);
    if (!v) return [];
    return JSON.parse(v);
  } catch {
    return [];
  }
}

export function saveBusy(b: BusySlot[]) {
  if (typeof window === 'undefined') return;
  localStorage.setItem(KEY_BUSY, JSON.stringify(b));
}

export function genId(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}

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

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

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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