posimai-roadmap/index.html

1185 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-roadmap">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
var t = localStorage.getItem('posimai-roadmap-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="Posimai プロジェクト課題・ロードマップ管理">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Roadmap">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>Posimai Roadmap</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Tab bar ── */
.tab-bar {
position: sticky;
top: 52px;
z-index: 90;
background: var(--bg);
border-bottom: 1px solid var(--border);
display: flex;
padding: 0 16px;
}
.tab-btn {
flex: none;
padding: 0 20px;
height: 40px;
font-size: 13px;
font-weight: 500;
color: var(--text2);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
letter-spacing: 0.01em;
}
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-btn:hover:not(.active) { color: var(--text); }
/* ── Views ── */
.view { display: none; }
.view.active { display: block; }
/* ── Board ── */
.board {
display: flex;
gap: 12px;
padding: 16px;
min-height: calc(100dvh - 92px);
overflow-x: auto;
align-items: flex-start;
}
.col {
flex: 0 0 268px;
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 1100px) { .col { flex: 1 1 0; min-width: 200px; } }
.col-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 2px 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.col-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text3);
flex: 1;
}
.col-count {
font-size: 11px;
font-weight: 600;
color: var(--text3);
background: var(--surface2);
border-radius: 99px;
padding: 1px 7px;
}
.col-next .col-label { color: var(--accent); }
.col-hold .col-label { color: #F59E0B; }
/* ── Task cards ── */
.task-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
cursor: pointer;
transition: border-color 0.12s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 5px;
}
.task-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.task-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.task-done { opacity: 0.45; }
.task-done:hover { opacity: 0.7; }
.task-app-chip {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.45;
}
.task-done .task-title { text-decoration: line-through; }
.task-note {
font-size: 11px;
color: var(--text3);
line-height: 1.4;
background: var(--surface2);
border-radius: 6px;
padding: 5px 8px;
border-left: 2px solid #F59E0B;
}
.task-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.task-by {
font-size: 10px;
font-weight: 600;
color: var(--text3);
background: var(--surface2);
border-radius: 99px;
padding: 1px 7px;
}
.task-date { font-size: 10px; color: var(--text3); margin-left: auto; }
.task-commit { font-size: 10px; color: var(--accent); font-family: monospace; text-decoration: none; }
.task-commit:hover { text-decoration: underline; }
.add-col-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
padding: 7px 12px;
background: none;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text3);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.add-col-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Milestones bar ── */
.milestones-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 16px 0;
}
.milestone-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 11px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 99px;
font-size: 12px;
font-weight: 500;
color: var(--text2);
}
.milestone-target { font-size: 10px; color: var(--text3); }
/* ── Apps view ── */
#view-apps { padding: 16px; max-width: 800px; margin: 0 auto; }
.app-section {
margin-bottom: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.app-section-header {
display: flex;
align-items: center;
padding: 11px 14px;
cursor: pointer;
gap: 8px;
user-select: none;
}
.app-section-header:hover { background: var(--surface2); }
.app-section-name { font-size: 13px; font-weight: 600; color: var(--text); flex: 1; }
.app-open-count {
font-size: 11px;
color: var(--text3);
background: var(--surface2);
border-radius: 99px;
padding: 1px 8px;
}
.has-next { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.app-section-body { display: none; border-top: 1px solid var(--border); }
.app-section.open .app-section-body { display: block; }
.app-task-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
}
.app-task-row:last-child { border-bottom: none; }
.status-chip {
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 99px;
margin-top: 2px;
text-transform: uppercase;
}
.chip-next { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.chip-backlog { background: var(--surface2); color: var(--text3); }
.chip-hold { background: color-mix(in srgb, #F59E0B 12%, transparent); color: #F59E0B; }
.chip-done { background: var(--surface2); color: var(--text3); }
.app-task-content { flex: 1; min-width: 0; }
.app-task-title {
font-size: 13px;
color: var(--text);
line-height: 1.4;
cursor: pointer;
}
.app-task-title:hover { color: var(--accent); }
.app-task-done .app-task-title { text-decoration: line-through; opacity: 0.5; }
.app-task-note { font-size: 11px; color: var(--text3); margin-top: 3px; line-height: 1.4; }
.app-section-addbtn {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 14px;
width: 100%;
background: none;
border: none;
color: var(--text3);
font-size: 12px;
cursor: pointer;
border-top: 1px solid var(--border);
transition: color 0.12s, background 0.12s;
}
.app-section-addbtn:hover { color: var(--accent); background: var(--surface2); }
/* ── Ideas view ── */
#view-ideas { padding: 16px; }
.ideas-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.idea-filter-btn {
padding: 5px 13px;
border-radius: 99px;
font-size: 12px;
font-weight: 500;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text2);
cursor: pointer;
transition: all 0.12s;
}
.idea-filter-btn.active, .idea-filter-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: var(--accent);
color: var(--accent);
}
.ideas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.idea-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
cursor: pointer;
transition: border-color 0.12s;
display: flex;
flex-direction: column;
gap: 5px;
}
.idea-card:hover { border-color: var(--accent); }
.idea-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.idea-status-chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
padding: 2px 8px;
border-radius: 99px;
align-self: flex-start;
text-transform: uppercase;
}
.i-exploring { background: color-mix(in srgb, #8B5CF6 14%, transparent); color: #8B5CF6; }
.i-candidate { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.i-approved { background: color-mix(in srgb, #3B82F6 14%, transparent); color: #3B82F6; }
.i-archived { background: var(--surface2); color: var(--text3); }
.idea-title { font-size: 14px; font-weight: 600; color: var(--text); }
.idea-desc { font-size: 12px; color: var(--text2); line-height: 1.4; }
.idea-note { font-size: 11px; color: var(--text3); }
.idea-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.idea-by { font-size: 10px; color: var(--text3); }
.idea-date { font-size: 10px; color: var(--text3); margin-left: auto; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: max(24px, env(safe-area-inset-bottom, 24px));
right: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--accent);
color: #0D0D0D;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 40%, transparent);
transition: transform 0.12s, box-shadow 0.12s;
z-index: 100;
}
.fab:hover { transform: scale(1.08); box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 50%, transparent); }
.fab:active { transform: scale(0.96); }
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
z-index: 200;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 440px;
max-height: 90dvh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
gap: 8px;
position: sticky;
top: 0;
background: var(--surface);
}
.modal-title { font-size: 14px; font-weight: 600; flex: 1; }
.modal-body { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.field-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 5px;
}
.modal-body input[type="text"],
.modal-body textarea,
.modal-body select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
font-family: Inter, sans-serif;
padding: 9px 12px;
box-sizing: border-box;
outline: none;
transition: border-color 0.12s;
}
.modal-body input:focus,
.modal-body textarea:focus,
.modal-body select:focus { border-color: var(--accent); }
.modal-body textarea { min-height: 72px; resize: vertical; }
.modal-body select option { background: var(--surface); }
.status-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.spick {
padding: 5px 13px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1.5px solid var(--border);
background: none;
color: var(--text2);
transition: all 0.1s;
}
.spick[data-s="next"].sel { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.spick[data-s="backlog"].sel { border-color: var(--text3); color: var(--text); background: var(--surface2); }
.spick[data-s="hold"].sel { border-color: #F59E0B; color: #F59E0B; background: color-mix(in srgb, #F59E0B 12%, transparent); }
.spick[data-s="done"].sel { border-color: var(--text3); color: var(--text3); background: var(--surface2); }
.spick[data-s="exploring"].sel { border-color: #8B5CF6; color: #8B5CF6; background: color-mix(in srgb, #8B5CF6 12%, transparent); }
.spick[data-s="candidate"].sel { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.spick[data-s="approved"].sel { border-color: #3B82F6; color: #3B82F6; background: color-mix(in srgb, #3B82F6 12%, transparent); }
.spick[data-s="archived"].sel { border-color: var(--text3); color: var(--text3); background: var(--surface2); }
.modal-footer {
display: flex;
gap: 8px;
padding: 12px 18px 18px;
justify-content: flex-end;
}
.btn-danger {
margin-right: auto;
background: none;
border: 1px solid var(--border);
color: #EF4444;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
}
.btn-danger:hover { border-color: #EF4444; background: color-mix(in srgb, #EF4444 10%, transparent); }
.btn-primary {
background: var(--accent);
color: #0D0D0D;
font-size: 13px;
font-weight: 600;
padding: 8px 18px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: opacity 0.12s;
}
.btn-primary:hover { opacity: 0.88; }
.btn-secondary {
background: var(--surface2);
color: var(--text);
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
}
/* ── Settings additions ── */
.settings-action-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
cursor: pointer;
margin-bottom: 6px;
font-family: Inter, sans-serif;
text-align: left;
transition: border-color 0.12s, color 0.12s;
}
.settings-action-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Empty states ── */
.empty-col { padding: 16px 8px; text-align: center; color: var(--text3); font-size: 12px; opacity: 0.7; }
.empty-grid {
grid-column: 1 / -1;
text-align: center;
padding: 60px 24px;
color: var(--text3);
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link" tabindex="0" style="position:absolute;top:-100%;left:8px;background:var(--accent);color:#0D0D0D;padding:8px 16px;border-radius:8px;font-weight:600;font-size:13px;z-index:10000;text-decoration:none">コンテンツへスキップ</a>
<!-- Settings panel -->
<aside class="settings-panel" id="settingsPanel" role="complementary">
<div class="settings-panel-header">
<span class="settings-panel-title">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<div class="settings-item-label">テーマ</div>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark"><i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク</button>
<button class="theme-btn" data-theme-val="light"><i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト</button>
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
</div>
</div>
</div>
<div style="margin-top:16px;">
<div class="settings-group-label">データ</div>
<button class="settings-action-btn" id="exportJsonBtn">
<i data-lucide="copy" style="width:14px;height:14px;stroke-width:1.75"></i>
JSON をコピーClaude 連携用)
</button>
<button class="settings-action-btn" id="reloadJsonBtn">
<i data-lucide="refresh-cw" style="width:14px;height:14px;stroke-width:1.75"></i>
ファイルから再読み込み
</button>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<header class="header">
<div class="header-brand">
<div class="header-dot" aria-hidden="true"></div>
<span class="header-title">Roadmap</span>
</div>
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
</button>
</header>
<div class="tab-bar" role="tablist">
<button class="tab-btn active" data-view="board" role="tab" aria-selected="true">Board</button>
<button class="tab-btn" data-view="apps" role="tab" aria-selected="false">Apps</button>
<button class="tab-btn" data-view="ideas" role="tab" aria-selected="false">Ideas</button>
</div>
<main id="main-content">
<div id="view-board" class="view active" role="tabpanel" aria-label="Board"></div>
<div id="view-apps" class="view" role="tabpanel" aria-label="Apps"></div>
<div id="view-ideas" class="view" role="tabpanel" aria-label="Ideas"></div>
</main>
<button class="fab" id="fabBtn" aria-label="タスクを追加">
<i data-lucide="plus" style="width:22px;height:22px;stroke-width:2.5"></i>
</button>
<!-- Task modal -->
<div class="modal-overlay" id="taskModal" role="dialog" aria-modal="true" aria-labelledby="taskModalTitle">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="taskModalTitle">タスクを追加</span>
<button class="icon-btn" id="taskModalClose" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div>
<div class="field-label">タイトル</div>
<input type="text" id="fTitle" placeholder="タスクのタイトル...">
</div>
<div>
<div class="field-label">アプリ</div>
<select id="fApp"></select>
</div>
<div>
<div class="field-label">ステータス</div>
<div class="status-picker" id="taskStatusPicker">
<button class="spick" data-s="next">Next</button>
<button class="spick" data-s="backlog">Backlog</button>
<button class="spick" data-s="hold">Hold</button>
<button class="spick" data-s="done">Done</button>
</div>
</div>
<div>
<div class="field-label">メモ / 保留の理由</div>
<textarea id="fNote" placeholder="補足情報や保留の理由..."></textarea>
</div>
<div>
<div class="field-label">担当</div>
<select id="fBy"></select>
</div>
<div id="fCommitRow" style="display:none">
<div class="field-label">Commit hash任意</div>
<input type="text" id="fCommit" placeholder="例: e7ccd82">
</div>
</div>
<div class="modal-footer">
<button class="btn-danger" id="deleteTaskBtn" style="display:none">削除</button>
<button class="btn-secondary" id="taskModalCancel">キャンセル</button>
<button class="btn-primary" id="saveTaskBtn">保存</button>
</div>
</div>
</div>
<!-- Idea modal -->
<div class="modal-overlay" id="ideaModal" role="dialog" aria-modal="true" aria-labelledby="ideaModalTitle">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="ideaModalTitle">アイデアを追加</span>
<button class="icon-btn" id="ideaModalClose" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div>
<div class="field-label">タイトル</div>
<input type="text" id="iTitle" placeholder="例: posimai-finance">
</div>
<div>
<div class="field-label">説明</div>
<input type="text" id="iDesc" placeholder="一行で説明">
</div>
<div>
<div class="field-label">ステータス</div>
<div class="status-picker" id="ideaStatusPicker">
<button class="spick" data-s="exploring">Exploring</button>
<button class="spick" data-s="candidate">Candidate</button>
<button class="spick" data-s="approved">Approved</button>
<button class="spick" data-s="archived">Archived</button>
</div>
</div>
<div>
<div class="field-label">メモ</div>
<textarea id="iNote" placeholder="補足情報..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-danger" id="deleteIdeaBtn" style="display:none">削除</button>
<button class="btn-secondary" id="ideaModalCancel">キャンセル</button>
<button class="btn-primary" id="saveIdeaBtn">保存</button>
</div>
</div>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script defer>
// ── Constants ─────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'posimai-roadmap-data';
const STATUSES = [
{ id: 'next', label: 'Next', icon: 'circle-arrow-right' },
{ id: 'backlog', label: 'Backlog', icon: 'inbox' },
{ id: 'hold', label: 'Hold', icon: 'circle-pause' },
{ id: 'done', label: 'Done', icon: 'circle-check' },
];
const IDEA_STATUSES = ['exploring', 'candidate', 'approved', 'archived'];
const IDEA_LABELS = { exploring: 'Exploring', candidate: 'Candidate', approved: 'Approved', archived: 'Archived' };
// ── State ─────────────────────────────────────────────────────────────────────
let data = null;
let currentView = 'board';
let editTaskId = null;
let editTaskApp = null;
let editIdeaId = null;
let taskStatus = 'next';
let ideaStatus = 'exploring';
let ideaFilter = 'all';
// ── Data helpers ──────────────────────────────────────────────────────────────
async function loadData(forceFile = false) {
if (!forceFile) {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) { try { data = JSON.parse(raw); return; } catch (_) {} }
}
const res = await fetch('/roadmap.json');
data = await res.json();
persist();
}
function persist() {
data.updated = new Date().toISOString().slice(0, 10);
localStorage.setItem(STORAGE_KEY, JSON.stringify(data, null, 2));
}
function allTasks() {
const out = [];
for (const app of data.apps) {
for (const t of app.tasks) out.push({ ...t, appId: app.id });
}
for (const t of (data.global || [])) out.push({ ...t, appId: 'global' });
return out;
}
function findTask(id, appId) {
if (appId === 'global') return (data.global || []).find(t => t.id === id);
const app = data.apps.find(a => a.id === appId);
return app ? app.tasks.find(t => t.id === id) : null;
}
function removeTask(id, appId) {
if (appId === 'global') {
data.global = (data.global || []).filter(t => t.id !== id);
} else {
const app = data.apps.find(a => a.id === appId);
if (app) app.tasks = app.tasks.filter(t => t.id !== id);
}
}
function pushTask(task, appId) {
if (appId === 'global') {
if (!data.global) data.global = [];
data.global.push(task);
} else {
const app = data.apps.find(a => a.id === appId);
if (app) app.tasks.push(task);
}
}
function genId(prefix) { return `${prefix}-${Date.now().toString(36)}`; }
// ── Render helpers ────────────────────────────────────────────────────────────
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function shortName(appId) { return appId.replace('posimai-', ''); }
function taskCard(t) {
const done = t.status === 'done';
const note = (t.status === 'hold' && t.note)
? `<div class="task-note">${esc(t.note)}</div>` : '';
const commit = t.commit
? `<a class="task-commit" href="https://github.com/posimai/${t.appId}/commit/${t.commit}" target="_blank" rel="noopener">${t.commit.slice(0,7)}</a>` : '';
const date = (done && t.done_at) ? `<span class="task-date">${t.done_at}</span>` : '';
return `<div class="task-card${done?' task-done':''}" data-id="${t.id}" data-app="${t.appId}" role="button" tabindex="0" aria-label="${esc(t.title)}">
<div class="task-app-chip">${shortName(t.appId)}</div>
<div class="task-title">${esc(t.title)}</div>
${note}
<div class="task-meta">
<span class="task-by">${esc(t.by||'mai')}</span>
${date}${commit}
</div>
</div>`;
}
// ── Board view ────────────────────────────────────────────────────────────────
function renderBoard() {
const tasks = allTasks();
const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 30);
let html = '';
if (data.milestones?.length) {
html += '<div class="milestones-bar">';
for (const ms of data.milestones) {
html += `<span class="milestone-chip"><i data-lucide="flag" style="width:11px;height:11px;stroke-width:2;opacity:.6"></i>${esc(ms.title)}<span class="milestone-target">${ms.target}</span></span>`;
}
html += '</div>';
}
html += '<div class="board">';
for (const st of STATUSES) {
let col = tasks.filter(t => t.status === st.id);
if (st.id === 'done') {
col = col.filter(t => !t.done_at || new Date(t.done_at) >= cutoff).slice(0, 12);
}
const addBtn = st.id !== 'done'
? `<button class="add-col-btn" data-add-status="${st.id}"><i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>追加</button>` : '';
html += `<div class="col col-${st.id}">
<div class="col-header">
<i data-lucide="${st.icon}" style="width:13px;height:13px;stroke-width:2;opacity:.55"></i>
<span class="col-label">${st.label}</span>
<span class="col-count">${col.length}</span>
</div>
${col.length ? col.map(taskCard).join('') : '<div class="empty-col">なし</div>'}
${addBtn}
</div>`;
}
html += '</div>';
document.getElementById('view-board').innerHTML = html;
}
// ── Apps view ─────────────────────────────────────────────────────────────────
function renderApps() {
const sections = [...data.apps, { id: 'global', tasks: data.global || [] }];
const statusOrder = { next: 0, backlog: 1, hold: 2, done: 3 };
let html = '';
for (const app of sections) {
const open = app.tasks.filter(t => t.status !== 'done');
const hasNext = open.some(t => t.status === 'next');
const autoOpen = hasNext || open.length > 0;
const icon = app.id === 'global' ? 'globe' : 'package';
html += `<div class="app-section${autoOpen?' open':''}" data-section="${app.id}">
<div class="app-section-header">
<i data-lucide="${icon}" style="width:14px;height:14px;stroke-width:1.75;opacity:.45"></i>
<span class="app-section-name">${shortName(app.id)}</span>
<span class="app-open-count${hasNext?' has-next':''}">${open.length} open</span>
<i data-lucide="chevron-down" style="width:14px;height:14px;stroke-width:1.75;opacity:.35"></i>
</div>
<div class="app-section-body">`;
const sorted = [...app.tasks].sort((a, b) => (statusOrder[a.status]??9) - (statusOrder[b.status]??9));
if (!sorted.length) {
html += '<div style="padding:10px 14px;font-size:12px;color:var(--text3);">タスクなし</div>';
}
for (const t of sorted) {
const note = t.note ? `<div class="app-task-note">${esc(t.note)}</div>` : '';
const commit = t.commit
? `<a class="task-commit" href="https://github.com/posimai/${app.id}/commit/${t.commit}" target="_blank" rel="noopener">${t.commit.slice(0,7)}</a>` : '';
html += `<div class="app-task-row${t.status==='done'?' app-task-done':''}">
<span class="status-chip chip-${t.status}">${t.status}</span>
<div class="app-task-content">
<div class="app-task-title" data-id="${t.id}" data-app="${app.id}" role="button" tabindex="0">${esc(t.title)}</div>
${note}
<div class="task-meta" style="margin-top:4px">
<span class="task-by">${esc(t.by||'mai')}</span>
${t.done_at?`<span class="task-date">${t.done_at}</span>`:''}
${commit}
</div>
</div>
</div>`;
}
html += `<button class="app-section-addbtn" data-add-status="next" data-add-app="${app.id}">
<i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>タスクを追加
</button></div></div>`;
}
document.getElementById('view-apps').innerHTML = html;
}
// ── Ideas view ────────────────────────────────────────────────────────────────
function renderIdeas() {
const ideas = data.ideas || [];
const counts = {};
for (const s of IDEA_STATUSES) counts[s] = ideas.filter(i => i.status === s).length;
let html = '<div class="ideas-top">';
html += `<button class="idea-filter-btn${ideaFilter==='all'?' active':''}" data-ifilter="all">All ${ideas.length}</button>`;
for (const s of IDEA_STATUSES) {
html += `<button class="idea-filter-btn${ideaFilter===s?' active':''}" data-ifilter="${s}">${IDEA_LABELS[s]} ${counts[s]}</button>`;
}
html += `<button class="idea-filter-btn" id="addIdeaBtn" style="margin-left:auto;border-style:dashed">
<i data-lucide="plus" style="width:11px;height:11px;stroke-width:2.5;vertical-align:-1px"></i> 追加
</button></div>`;
const filtered = ideaFilter === 'all' ? ideas : ideas.filter(i => i.status === ideaFilter);
html += '<div class="ideas-grid">';
if (!filtered.length) {
html += `<div class="empty-grid">
<i data-lucide="lightbulb" style="width:28px;height:28px;stroke-width:1.25;margin-bottom:8px;display:block;margin-inline:auto;opacity:.3"></i>
<div style="font-size:13px;">アイデアなし</div>
</div>`;
}
for (const idea of filtered) {
html += `<div class="idea-card" data-idea-id="${idea.id}" role="button" tabindex="0">
<span class="idea-status-chip i-${idea.status}">${IDEA_LABELS[idea.status]}</span>
<div class="idea-title">${esc(idea.title)}</div>
${idea.description ? `<div class="idea-desc">${esc(idea.description)}</div>` : ''}
${idea.note ? `<div class="idea-note">${esc(idea.note)}</div>` : ''}
<div class="idea-meta">
<span class="idea-by">${esc(idea.by||'mai')}</span>
<span class="idea-date">${idea.created}</span>
</div>
</div>`;
}
html += '</div>';
document.getElementById('view-ideas').innerHTML = html;
}
function renderAll() {
renderBoard();
renderApps();
renderIdeas();
lucide.createIcons();
}
// ── View switching ────────────────────────────────────────────────────────────
function switchView(v) {
currentView = v;
document.querySelectorAll('.tab-btn').forEach(b => {
const on = b.dataset.view === v;
b.classList.toggle('active', on);
b.setAttribute('aria-selected', on);
});
document.querySelectorAll('.view').forEach(el => {
el.classList.toggle('active', el.id === `view-${v}`);
});
document.getElementById('fabBtn').style.display = v === 'ideas' ? 'none' : 'flex';
}
// ── Task modal ────────────────────────────────────────────────────────────────
function pickStatus(s, pickerId) {
if (pickerId === 'taskStatusPicker') taskStatus = s;
else ideaStatus = s;
document.querySelectorAll(`#${pickerId} .spick`).forEach(b => {
b.classList.toggle('sel', b.dataset.s === s);
});
if (pickerId === 'taskStatusPicker') {
document.getElementById('fCommitRow').style.display = s === 'done' ? '' : 'none';
}
}
function openTaskModal({ taskId, appId, defaultStatus, defaultApp } = {}) {
editTaskId = taskId || null;
editTaskApp = appId || null;
// Populate selects
const fApp = document.getElementById('fApp');
fApp.innerHTML = '<option value="global">global</option>';
for (const app of data.apps) {
fApp.innerHTML += `<option value="${app.id}">${shortName(app.id)}</option>`;
}
const fBy = document.getElementById('fBy');
fBy.innerHTML = (data.members || ['mai']).map(m => `<option value="${m}">${m}</option>`).join('');
if (taskId) {
const t = findTask(taskId, appId);
if (!t) return;
document.getElementById('taskModalTitle').textContent = 'タスクを編集';
document.getElementById('fTitle').value = t.title;
document.getElementById('fNote').value = t.note || '';
document.getElementById('fCommit').value = t.commit || '';
fApp.value = appId;
fBy.value = t.by || 'mai';
pickStatus(t.status, 'taskStatusPicker');
document.getElementById('deleteTaskBtn').style.display = '';
} else {
document.getElementById('taskModalTitle').textContent = 'タスクを追加';
document.getElementById('fTitle').value = '';
document.getElementById('fNote').value = '';
document.getElementById('fCommit').value = '';
fApp.value = defaultApp || 'global';
pickStatus(defaultStatus || 'next', 'taskStatusPicker');
document.getElementById('deleteTaskBtn').style.display = 'none';
}
document.getElementById('taskModal').classList.add('open');
document.getElementById('fTitle').focus();
}
function closeTaskModal() {
document.getElementById('taskModal').classList.remove('open');
editTaskId = editTaskApp = null;
}
function saveTask() {
const title = document.getElementById('fTitle').value.trim();
if (!title) { showToast('タイトルを入力してください'); return; }
const appId = document.getElementById('fApp').value;
const by = document.getElementById('fBy').value;
const note = document.getElementById('fNote').value.trim() || null;
const commit = document.getElementById('fCommit').value.trim() || null;
const today = new Date().toISOString().slice(0, 10);
if (editTaskId) {
const t = findTask(editTaskId, editTaskApp);
if (!t) return;
t.title = title;
t.note = note;
t.by = by;
t.commit = commit;
if (t.status !== taskStatus) {
t.status = taskStatus;
if (taskStatus === 'done' && !t.done_at) t.done_at = today;
}
// Move app if changed
if (editTaskApp !== appId) {
removeTask(editTaskId, editTaskApp);
pushTask({ ...t }, appId);
}
} else {
pushTask({
id: genId(shortName(appId)),
title, status: taskStatus, note, by,
created: today,
commit,
done_at: taskStatus === 'done' ? today : null,
}, appId);
}
persist(); closeTaskModal(); renderAll();
showToast(editTaskId ? '更新しました' : '追加しました');
}
function deleteTask() {
if (!editTaskId || !confirm('このタスクを削除しますか?')) return;
removeTask(editTaskId, editTaskApp);
persist(); closeTaskModal(); renderAll();
showToast('削除しました');
}
// ── Idea modal ────────────────────────────────────────────────────────────────
function openIdeaModal(ideaId) {
editIdeaId = ideaId || null;
if (ideaId) {
const idea = (data.ideas || []).find(i => i.id === ideaId);
if (!idea) return;
document.getElementById('ideaModalTitle').textContent = 'アイデアを編集';
document.getElementById('iTitle').value = idea.title;
document.getElementById('iDesc').value = idea.description || '';
document.getElementById('iNote').value = idea.note || '';
pickStatus(idea.status, 'ideaStatusPicker');
document.getElementById('deleteIdeaBtn').style.display = '';
} else {
document.getElementById('ideaModalTitle').textContent = 'アイデアを追加';
document.getElementById('iTitle').value = '';
document.getElementById('iDesc').value = '';
document.getElementById('iNote').value = '';
pickStatus('exploring', 'ideaStatusPicker');
document.getElementById('deleteIdeaBtn').style.display = 'none';
}
document.getElementById('ideaModal').classList.add('open');
document.getElementById('iTitle').focus();
}
function closeIdeaModal() {
document.getElementById('ideaModal').classList.remove('open');
editIdeaId = null;
}
function saveIdea() {
const title = document.getElementById('iTitle').value.trim();
if (!title) { showToast('タイトルを入力してください'); return; }
const desc = document.getElementById('iDesc').value.trim() || null;
const note = document.getElementById('iNote').value.trim() || null;
if (!data.ideas) data.ideas = [];
if (editIdeaId) {
const idea = data.ideas.find(i => i.id === editIdeaId);
if (!idea) return;
idea.title = title; idea.description = desc; idea.note = note; idea.status = ideaStatus;
} else {
data.ideas.push({
id: genId('idea'), title, description: desc,
status: ideaStatus, note,
by: (data.members || ['mai'])[0],
created: new Date().toISOString().slice(0, 10),
});
}
persist(); closeIdeaModal(); renderIdeas(); lucide.createIcons();
showToast(editIdeaId ? '更新しました' : 'アイデアを追加しました');
}
function deleteIdea() {
if (!editIdeaId || !confirm('このアイデアを削除しますか?')) return;
data.ideas = data.ideas.filter(i => i.id !== editIdeaId);
persist(); closeIdeaModal(); renderIdeas(); lucide.createIcons();
showToast('削除しました');
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', e => {
// Tabs
const tab = e.target.closest('.tab-btn[data-view]');
if (tab) { switchView(tab.dataset.view); return; }
// Board task card
const card = e.target.closest('.task-card');
if (card && !e.target.closest('a')) {
openTaskModal({ taskId: card.dataset.id, appId: card.dataset.app }); return;
}
// Apps task title
const atitle = e.target.closest('.app-task-title[data-id]');
if (atitle && !e.target.closest('a')) {
openTaskModal({ taskId: atitle.dataset.id, appId: atitle.dataset.app }); return;
}
// Add task buttons
const addBtn = e.target.closest('[data-add-status]');
if (addBtn) {
openTaskModal({ defaultStatus: addBtn.dataset.addStatus, defaultApp: addBtn.dataset.addApp || null }); return;
}
// App section toggle
const header = e.target.closest('.app-section-header');
if (header) { header.closest('.app-section')?.classList.toggle('open'); return; }
// Idea card
const icard = e.target.closest('.idea-card[data-idea-id]');
if (icard) { openIdeaModal(icard.dataset.ideaId); return; }
// Idea filter
const ifilt = e.target.closest('[data-ifilter]');
if (ifilt) { ideaFilter = ifilt.dataset.ifilter; renderIdeas(); lucide.createIcons(); return; }
// Add idea
if (e.target.closest('#addIdeaBtn')) { openIdeaModal(null); return; }
// FAB
if (e.target.closest('#fabBtn')) { openTaskModal({ defaultStatus: 'next' }); return; }
// Status pickers
const spick = e.target.closest('.spick');
if (spick) {
const picker = spick.closest('.status-picker');
if (picker) pickStatus(spick.dataset.s, picker.id);
return;
}
// Task modal controls
if (e.target.closest('#taskModalClose') || e.target.closest('#taskModalCancel')) { closeTaskModal(); return; }
if (e.target.id === 'taskModal') { closeTaskModal(); return; }
if (e.target.closest('#saveTaskBtn')) { saveTask(); return; }
if (e.target.closest('#deleteTaskBtn')) { deleteTask(); return; }
// Idea modal controls
if (e.target.closest('#ideaModalClose') || e.target.closest('#ideaModalCancel')) { closeIdeaModal(); return; }
if (e.target.id === 'ideaModal') { closeIdeaModal(); return; }
if (e.target.closest('#saveIdeaBtn')) { saveIdea(); return; }
if (e.target.closest('#deleteIdeaBtn')) { deleteIdea(); return; }
// Settings actions
if (e.target.closest('#exportJsonBtn')) {
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
.then(() => showToast('JSON をクリップボードにコピーしました'))
.catch(() => showToast('コピーに失敗しました'));
return;
}
if (e.target.closest('#reloadJsonBtn')) {
if (confirm('ファイルから再読み込みします。ローカルの変更は失われます。')) {
localStorage.removeItem(STORAGE_KEY);
loadData(true).then(() => { renderAll(); showToast('再読み込みしました'); });
}
return;
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeTaskModal(); closeIdeaModal(); }
if (e.key === 'Enter' && (e.target.matches('.task-card') || e.target.matches('.idea-card'))) e.target.click();
});
// ── Boot ──────────────────────────────────────────────────────────────────────
loadData().then(() => {
renderAll();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
});
</script>
</body>
</html>