【AIとつくってみた】やる気がある”今”を未来に届けるツールを作った話
⬛︎夜中の「よし、やるぞ!」が翌朝には消えている案件
夜中にふと思いつくことがあります。
「明日から本気出す」 「よし、これやろう」 「今度こそ続けるぞ」
その瞬間は、確かに本気だった。なんなら眠れないほどに。
でも翌朝起きると、あの熱はどこへやら。
「まぁ、今日はいいか…」で終わってしまうことも多々あります。
でもこれ、私だけじゃないはずです。
⬛︎モチベーションは保存できない
お金みたいに貯金できたらいいのに…
でもモチベーションって、そうはいかないのです。
ある時は溢れるほどあって、 ない時は本当に何も湧いてこない。
そして厄介なことに、
「やる気がある時」と「実際に行動できる時」が、ズレる。
そこで私はこう思ったのです。
やる気がある”今”を、未来に残しておけないだろうか?
⬛︎AIに手伝ってもらって、ツールを作った
そこで作ったのが、「未来の自分に手紙を送るツール」。

- 今、何を思っているのか
- なぜそれをやりたいのか
- いつの自分に伝えたい言葉なのか
それを書いて、残すだけ。
予定管理アプリでもなく、
TODOリストでもなく、
自己啓発ツールでもない。
そう、ただの「手紙」です。
⬛︎実際に使ってみて気づいたこと
書いてみると、不思議なことにこれが刺さるのです。
未来の自分に向けて書くせいか、変にカッコつけられない。
言い訳も、建前もできない。 だから正直な言葉が出てくる。
数日後、やる気が落ちた時に読み返すと、
「あ、この時の自分本気だったな」
って思える。
それだけでいいんです。
誰かに叱られるんじゃなくて、過去の自分に励まされる感覚。
今の俺も頑張んなきゃと少し思える。
やる気に溢れている自分の言葉って、
思っている以上に力があります。
⬛︎こんな人に向いていると思う
このツールは、こんな人に使ってほしいです。
- やる気はあるのに、行動が続かない人
- 何か始めたいけど、踏み出せない人
- 自分を責めがちな人
未来の自分を叱るんじゃなく、 ちょっとだけ励ましてあげる。
やる気を分けてあげる。
それだけで、自分の人生が少し良くなるかも知れません。
⬛︎最後に
このツールで人生が劇的に変わる、とは言わいません。
でも、
「何もしないよりは、ちょっと前に進めるかも」
そのくらいの存在にはなれると思います。
もし今、少しでもやる気があるなら。
その気持ちが消える前に、
未来の自分に一言残しておくのもアリかもしれません。
それと今回作ったツールのコードを貼っておきます。
こうした方が使いやすいんだけどなどあればガンガン改造して、
自分用にアップグレードしてみてください!
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>未来の自分への手紙|一覧・タグ・検索</title>
<style>
:root{
--bg:#f7f1e8;
--card:#ffffff;
--text:#3b2f2a;
--sub:#7a6a60;
--accent:#c77d5a;
--line:#eadfd6;
--shadow:0 12px 28px rgba(0,0,0,.08);
--r:18px;
--ok:#2f855a;
--warn:#b7791f;
--bad:#a23b3b;
}
*{box-sizing:border-box}
body{
margin:0;
font-family:-apple-system,BlinkMacSystemFont,"Hiragino Sans","Yu Gothic",sans-serif;
background:linear-gradient(180deg,var(--bg),#fff);
color:var(--text);
}
.wrap{max-width:1200px;margin:0 auto;padding:18px}
header{padding:12px 8px 6px}
h1{margin:0;font-size:1.35rem;letter-spacing:.02em}
.desc{margin:6px 0 0;color:var(--sub);font-size:.95rem;line-height:1.5}
.grid{
display:grid;
grid-template-columns:1fr;
gap:14px;
margin-top:14px;
}
@media(min-width:1060px){
.grid{grid-template-columns:1.02fr .98fr}
}
.card{
background:var(--card);
border:1px solid var(--line);
border-radius:var(--r);
box-shadow:var(--shadow);
padding:16px;
}
label{
display:block;
font-size:.9rem;
color:var(--sub);
margin:12px 0 6px;
}
input, select, textarea{
width:100%;
border:1px solid var(--line);
border-radius:12px;
padding:12px 12px;
font-size:1rem;
outline:none;
background:#fff;
color:var(--text);
}
textarea{min-height:130px;resize:vertical;line-height:1.65}
input:focus,select:focus,textarea:focus{
border-color:rgba(199,125,90,.6);
box-shadow:0 0 0 4px rgba(199,125,90,.12);
}
.row{
display:grid;
grid-template-columns:1fr;
gap:12px;
}
@media(min-width:560px){
.row{grid-template-columns:1fr 1fr}
}
.btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}
button{
border:0;
border-radius:999px;
padding:11px 14px;
font-size:.98rem;
cursor:pointer;
transition:.15s transform,.15s opacity;
user-select:none;
}
button:active{transform:scale(.98)}
.primary{background:var(--accent);color:#fff}
.ghost{background:#fff;border:1px solid var(--line);color:var(--text)}
.danger{background:#fff;border:1px solid #f1c7c7;color:var(--bad)}
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.chip{
background:#fff;
border:1px solid var(--line);
border-radius:999px;
padding:7px 10px;
font-size:.9rem;
cursor:pointer;
}
.chip:hover{border-color:rgba(199,125,90,.6)}
.chip.on{border-color:rgba(199,125,90,.75); box-shadow:0 0 0 3px rgba(199,125,90,.12)}
.badge{
display:inline-flex;
align-items:center;
gap:8px;
padding:7px 10px;
border-radius:999px;
border:1px solid var(--line);
color:var(--sub);
font-size:.88rem;
background:#fff;
margin-top:10px;
}
.badge strong{color:var(--text)}
.small{font-size:.86rem;color:var(--sub);margin-top:10px;line-height:1.55}
.toast{
position:fixed;
left:50%;
bottom:18px;
transform:translateX(-50%);
background:rgba(0,0,0,.82);
color:#fff;
padding:10px 14px;
border-radius:999px;
font-size:.92rem;
opacity:0;
pointer-events:none;
transition:.18s opacity,.18s transform;
z-index:50;
}
.toast.show{
opacity:1;
transform:translateX(-50%) translateY(-4px);
}
/* List */
.toolbar{
display:grid;
gap:10px;
grid-template-columns:1fr;
margin-top:10px;
}
@media(min-width:720px){
.toolbar{grid-template-columns:1.2fr .8fr .8fr}
}
.list{margin-top:12px;display:grid;gap:10px}
.item{
border:1px solid var(--line);
border-radius:16px;
padding:12px;
background:#fff;
}
.itemHead{
display:flex;
gap:10px;
justify-content:space-between;
align-items:flex-start;
flex-wrap:wrap;
}
.meta{
display:flex;
flex-wrap:wrap;
gap:8px;
align-items:center;
color:var(--sub);
font-size:.86rem;
}
.pill{
display:inline-flex;
align-items:center;
gap:6px;
padding:5px 9px;
border-radius:999px;
border:1px solid var(--line);
background:#fff;
}
.pill.ok{border-color:rgba(47,133,90,.35); color:var(--ok)}
.pill.warn{border-color:rgba(183,121,31,.35); color:var(--warn)}
.pill.bad{border-color:rgba(162,59,59,.35); color:var(--bad)}
.title{
font-weight:700;
margin:0 0 6px;
font-size:1rem;
line-height:1.35;
}
.tags{
display:flex;flex-wrap:wrap;gap:6px;margin-top:8px
}
.tag{
font-size:.82rem;
padding:4px 8px;
border-radius:999px;
border:1px solid var(--line);
color:var(--sub);
background:#fff;
cursor:pointer;
}
.tag:hover{border-color:rgba(199,125,90,.6)}
.preview{
margin-top:10px;
white-space:pre-wrap;
line-height:1.7;
border-radius:14px;
padding:12px;
border:1px dashed rgba(199,125,90,.45);
background:#fff7f2;
font-size:.98rem;
display:none;
}
.locked{
margin-top:10px;
border-radius:14px;
padding:12px;
border:1px solid var(--line);
background:#fff;
color:var(--sub);
line-height:1.7;
}
.itemBtns{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.mini{
padding:8px 10px;
font-size:.9rem;
border-radius:999px;
}
.empty{
border:1px dashed rgba(0,0,0,.12);
border-radius:16px;
padding:14px;
color:var(--sub);
background:#fff;
line-height:1.6;
}
.split{
display:grid;
grid-template-columns:1fr;
gap:10px;
}
@media(min-width:720px){
.split{grid-template-columns:1fr 1fr}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>未来の自分へ(タイムカプセル)</h1>
<p class="desc">手紙を“複数”保管 → タグで整理 → 検索で掘り起こす。開封日はロック、当日以降に解禁。</p>
</header>
<div class="grid">
<!-- 作成 -->
<section class="card" aria-label="作成">
<div class="row">
<div>
<label for="meName">あなたの呼び名(任意)</label>
<input id="meName" type="text" placeholder="例:私 / 俺 / 自分" />
</div>
<div>
<label for="futureDate">開封日(未来の日付)</label>
<input id="futureDate" type="date" />
</div>
</div>
<div class="row">
<div>
<label for="mode">方向性</label>
<select id="mode">
<option value="encourage">応援する</option>
<option value="reflect">振り返る</option>
<option value="promise">約束する</option>
<option value="coach">鬼コーチ(背中を押す)</option>
</select>
</div>
<div>
<label for="length">長さ</label>
<select id="length">
<option value="short">短め</option>
<option value="normal" selected>ふつう</option>
<option value="long">長め</option>
</select>
</div>
</div>
<label for="title">タイトル(一覧で見やすく)</label>
<input id="title" type="text" placeholder="例:1ヶ月後の私へ / 迷いを抜ける手紙 / 目標への途中経過" />
<label for="tagsInput">タグ(カンマ区切り / 例:家族,仕事,健康)</label>
<input id="tagsInput" type="text" placeholder="例:家族, 副業, 習慣" />
<div class="chips" aria-label="おすすめタグ">
<button type="button" class="chip" data-tag="家族">家族</button>
<button type="button" class="chip" data-tag="仕事">仕事</button>
<button type="button" class="chip" data-tag="副業">副業</button>
<button type="button" class="chip" data-tag="健康">健康</button>
<button type="button" class="chip" data-tag="習慣">習慣</button>
<button type="button" class="chip" data-tag="メンタル">メンタル</button>
</div>
<label for="nowState">いまの状況(背景)</label>
<textarea id="nowState" placeholder="例:最近は仕事と育児でバタバタ。やりたいことはあるのに時間が足りない。だけど続けたい。"></textarea>
<div class="split">
<div>
<label for="goal">未来の自分に確認したいこと(目標/到達点)</label>
<input id="goal" type="text" placeholder="例:目標は達成できた? / 家族との時間は増えた?" />
</div>
<div>
<label for="oneAction">未来までに続けてほしい“たった1つの行動”(任意)</label>
<input id="oneAction" type="text" placeholder="例:毎日10分でも書く / 週1で改善する" />
</div>
</div>
<div class="chips" aria-label="ワンタップ例">
<button type="button" class="chip" data-fill-now="正直しんどい日もある。でも、やめたくない。">いま:しんどい</button>
<button type="button" class="chip" data-fill-now="家族のために、ちゃんと強くなりたい。">いま:家族</button>
<button type="button" class="chip" data-fill-now="迷ってる。けど、少しでも前に進みたい。">いま:迷い</button>
<button type="button" class="chip" data-fill-goal="あの目標、近づけた?">目標</button>
<button type="button" class="chip" data-fill-one="毎日10分だけでも継続">行動</button>
</div>
<div class="btns">
<button class="primary" id="create">手紙を作成(プレビュー)</button>
<button class="ghost" id="sealAdd">封印して追加(一覧へ)</button>
<button class="ghost" id="copyPreview">プレビューをコピー</button>
<button class="danger" id="resetForm">入力リセット</button>
</div>
<div class="badge" id="statusBadge">状態:<strong>未作成</strong></div>
<label style="margin-top:14px;">プレビュー</label>
<div id="preview" class="locked">ここにプレビューが出ます。</div>
<p class="small">※保存はこの端末のブラウザ内(LocalStorage)です。</p>
</section>
<!-- 一覧 -->
<section class="card" aria-label="一覧">
<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap;">
<div>
<div style="font-weight:800;margin-bottom:2px;">手紙一覧</div>
<div style="color:var(--sub);font-size:.9rem;line-height:1.5;">検索・タグ絞り込み・開封管理</div>
</div>
<div class="btns" style="margin:0;">
<button class="ghost mini" id="exportJson">エクスポート</button>
<button class="ghost mini" id="importJson">インポート</button>
<button class="danger mini" id="deleteAll">全削除</button>
</div>
</div>
<div class="toolbar">
<div>
<label for="q">検索(タイトル/状況/目標/タグ)</label>
<input id="q" type="text" placeholder="例:ゼロイチ / 家族 / 健康" />
</div>
<div>
<label for="statusFilter">状態</label>
<select id="statusFilter">
<option value="all" selected>すべて</option>
<option value="locked">封印中(未開封)</option>
<option value="openable">開封可能(未開封)</option>
<option value="opened">開封済み</option>
</select>
</div>
<div>
<label for="sort">並び順</label>
<select id="sort">
<option value="openDateAsc">開封日が近い順</option>
<option value="openDateDesc">開封日が遠い順</option>
<option value="createdDesc" selected>作成が新しい順</option>
<option value="createdAsc">作成が古い順</option>
</select>
</div>
</div>
<label style="margin-top:10px;">タグで絞り込み(複数OK)</label>
<div class="chips" id="tagFilterChips"></div>
<div class="badge" id="countBadge">件数:<strong>0</strong></div>
<div class="list" id="list"></div>
<div class="empty" id="empty" style="display:none;">
まだ手紙がないよ。<br>
左で作って「封印して追加」すると、ここに溜まっていく!
</div>
</section>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const $ = (id) => document.getElementById(id);
// Form
const meName = $("meName");
const futureDate = $("futureDate");
const mode = $("mode");
const length = $("length");
const titleEl = $("title");
const tagsInput = $("tagsInput");
const nowState = $("nowState");
const goal = $("goal");
const oneAction = $("oneAction");
const preview = $("preview");
const statusBadge = $("statusBadge");
// List UI
const q = $("q");
const statusFilter = $("statusFilter");
const sort = $("sort");
const tagFilterChips = $("tagFilterChips");
const list = $("list");
const empty = $("empty");
const countBadge = $("countBadge");
const toast = $("toast");
const KEY = "future_self_capsules_v2";
let activeTagFilters = new Set(); // selected tags to filter list
function showToast(msg){
toast.textContent = msg;
toast.classList.add("show");
setTimeout(()=>toast.classList.remove("show"), 1400);
}
function todayYMD(){
const d = new Date();
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}`;
}
function setBadge(text){
statusBadge.innerHTML = `状態:<strong>${text}</strong>`;
}
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function normalizeTags(raw){
const t = (raw || "")
.split(/,|、|\/|\n| +/)
.map(s => s.trim())
.filter(Boolean);
// unique + limit
return [...new Set(t)].slice(0, 10);
}
function ensureFutureDate(){
const fd = (futureDate.value || "").trim();
if(!fd){
alert("開封日を選んでね!");
futureDate.focus();
return null;
}
const t = todayYMD();
if(fd < t){
alert("開封日は今日以降にしてね!(過去日は不可)");
futureDate.focus();
return null;
}
return fd;
}
function buildLetter(){
const fd = ensureFutureDate();
if(!fd) return null;
const name = (meName.value || "俺").trim();
const t = (titleEl.value || "").trim();
const now = (nowState.value || "").trim();
const g = (goal.value || "").trim();
const act = (oneAction.value || "").trim();
if(!now){
alert("「いまの状況」を一言でも入れてね!");
nowState.focus();
return null;
}
const m = mode.value;
const len = length.value;
const open = {
encourage: [`${fd}の${name}へ。`, `${fd}の自分へ。`, `${fd}、未来の${name}へ。`],
reflect: [`${fd}の${name}へ。振り返りの手紙。`, `${fd}の自分へ。今の記録。`, `${fd}の${name}へ。今の俺はこうだ。`],
promise: [`${fd}の${name}へ。約束の手紙。`, `${fd}の自分へ。誓いを書く。`, `${fd}の${name}へ。俺はこうする。`],
coach: [`${fd}の${name}へ。言い訳は禁止。`, `${fd}の自分へ。甘えるな。`, `${fd}の${name}へ。覚悟確認。`]
};
const mid = {
encourage: ["大丈夫。ちゃんと進んでる。", "今のしんどさは、未来の余裕に変わる。", "小さい一歩は、ちゃんと積み上がる。"],
reflect: ["この時点の俺は、まだ迷ってた。", "焦りと希望が混ざってた。でも、やめる気はなかった。", "選択の積み重ねが、今の君を作ってる。"],
promise: ["俺は、やめない。小さくても続ける。", "自分との約束は破りたくない。", "未来の俺が胸を張れる選択をする。"],
coach: ["答えは行動。以上。", "感情はOK。停止はNG。", "才能じゃない。継続で殴れ。"]
};
const close = {
encourage: ["今日もおつかれ。", "ありがとう、ここまで来た。", "未来の俺、ちゃんと笑えてるか?"],
reflect: ["ここから先は、選択の連続だ。", "どうなってても、次に進め。", "過去の俺は、君を信じてる。"],
promise: ["約束を守れたなら誇っていい。", "守れなくても今日から再開でいい。", "続けた分だけ人生は変わる。"],
coach: ["言い訳より先に一手。", "今日の自分を裏切るな。", "今すぐ小さく動け。"]
};
const lines = [];
if(t) lines.push(`【${t}】`);
lines.push(pick(open[m]));
lines.push("");
lines.push(`いまの状況:${now}`);
if(len !== "short") lines.push(pick(mid[m]));
if(g){
lines.push("");
lines.push(`確認:${g}`);
}
if(act){
lines.push("");
lines.push(`これだけは続けて:${act}`);
} else if(len === "long"){
lines.push("");
lines.push("これだけは守って:『小さくても前進の形で終える』");
}
if(len === "long"){
lines.push("");
lines.push("上手くいってるなら:やり方を続けろ。");
lines.push("上手くいってないなら:原因を1個だけ特定して、次の一手を変えろ。");
}
lines.push("");
lines.push(pick(close[m]));
if(len === "long"){
lines.push("");
lines.push(`— ${name}(${todayYMD()}に書いた)`);
}
return lines.join("\n");
}
function setPreview(text, kind="作成済み(未封印)"){
if(!text){
preview.className = "locked";
preview.textContent = "ここにプレビューが出ます。";
setBadge("未作成");
return;
}
preview.className = "locked";
preview.style.whiteSpace = "pre-wrap";
preview.textContent = text;
setBadge(kind);
}
function loadCapsules(){
try{
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : [];
}catch(e){
return [];
}
}
function saveCapsules(arr){
localStorage.setItem(KEY, JSON.stringify(arr));
}
function uid(){
return "c_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
}
function canOpen(fd){
return todayYMD() >= fd;
}
function statusOf(item){
if(item.openedAt) return "opened";
if(item.sealed){
return canOpen(item.futureDate) ? "openable" : "locked";
}
return "draft";
}
function statusPill(item){
const s = statusOf(item);
if(s === "opened") return `<span class="pill ok">✅ 開封済み</span>`;
if(s === "openable") return `<span class="pill warn">📬 開封可能</span>`;
if(s === "locked") return `<span class="pill bad">🔒 封印中</span>`;
return `<span class="pill">📝 下書き</span>`;
}
function escapeHTML(str){
return (str || "").replace(/[&<>"']/g, s => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[s]));
}
function collectAllTags(items){
const set = new Set();
items.forEach(it => (it.tags || []).forEach(t => set.add(t)));
return [...set].sort((a,b)=>a.localeCompare(b,"ja"));
}
function rebuildTagFilterChips(items){
const tags = collectAllTags(items);
tagFilterChips.innerHTML = "";
if(tags.length === 0){
tagFilterChips.innerHTML = `<div style="color:var(--sub);font-size:.9rem;">(タグがまだありません)</div>`;
return;
}
tags.forEach(t=>{
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chip" + (activeTagFilters.has(t) ? " on" : "");
btn.textContent = t;
btn.addEventListener("click", ()=>{
if(activeTagFilters.has(t)) activeTagFilters.delete(t);
else activeTagFilters.add(t);
renderList();
});
tagFilterChips.appendChild(btn);
});
// クリアボタン
const clr = document.createElement("button");
clr.type = "button";
clr.className = "chip";
clr.textContent = "タグ絞り込み解除";
clr.addEventListener("click", ()=>{
activeTagFilters.clear();
renderList();
});
tagFilterChips.appendChild(clr);
}
function matchesQuery(item, query){
if(!query) return true;
const ql = query.toLowerCase();
const hay = [
item.title || "",
item.nowState || "",
item.goal || "",
(item.tags || []).join(" "),
].join(" ").toLowerCase();
return hay.includes(ql);
}
function matchesStatus(item, filter){
if(filter === "all") return true;
return statusOf(item) === filter;
}
function matchesTags(item){
if(activeTagFilters.size === 0) return true;
const tags = new Set(item.tags || []);
for(const t of activeTagFilters){
if(!tags.has(t)) return false;
}
return true;
}
function sortItems(items){
const key = sort.value;
const copy = [...items];
const by = (a,b) => (a < b ? -1 : a > b ? 1 : 0);
if(key === "openDateAsc"){
copy.sort((a,b)=>by(a.futureDate || "", b.futureDate || ""));
} else if(key === "openDateDesc"){
copy.sort((a,b)=>by(b.futureDate || "", a.futureDate || ""));
} else if(key === "createdAsc"){
copy.sort((a,b)=>by(a.createdAt || "", b.createdAt || ""));
} else { // createdDesc
copy.sort((a,b)=>by(b.createdAt || "", a.createdAt || ""));
}
return copy;
}
function renderList(){
const items = loadCapsules();
rebuildTagFilterChips(items);
const query = (q.value || "").trim();
const sf = statusFilter.value;
const filtered = sortItems(items)
.filter(it => matchesQuery(it, query))
.filter(it => matchesStatus(it, sf))
.filter(it => matchesTags(it));
countBadge.innerHTML = `件数:<strong>${filtered.length}</strong>`;
list.innerHTML = "";
empty.style.display = (items.length === 0) ? "block" : "none";
if(items.length > 0 && filtered.length === 0){
const el = document.createElement("div");
el.className = "empty";
el.textContent = "条件に一致する手紙がないよ。検索/状態/タグを確認してみて。";
list.appendChild(el);
return;
}
filtered.forEach(item=>{
const el = document.createElement("div");
el.className = "item";
const s = statusOf(item);
const title = item.title ? item.title : `${item.futureDate}の自分へ`;
const tagHtml = (item.tags || []).map(t => `<span class="tag" data-tag="${escapeHTML(t)}">${escapeHTML(t)}</span>`).join("");
const lockedMsg = `この手紙は ${item.futureDate} まで封印されています。`;
el.innerHTML = `
<div class="itemHead">
<div style="min-width:220px;flex:1;">
<p class="title">${escapeHTML(title)}</p>
<div class="meta">
<span class="pill">🗓 開封日 ${escapeHTML(item.futureDate || "-")}</span>
<span class="pill">📝 作成 ${escapeHTML(item.createdAt || "-")}</span>
${statusPill(item)}
</div>
<div class="tags">${tagHtml || `<span style="color:var(--sub);font-size:.86rem;">(タグなし)</span>`}</div>
</div>
</div>
<div class="${(s === "locked") ? "locked" : "locked"}" id="box_${item.id}">
${(s === "locked")
? `🔒 ${escapeHTML(lockedMsg)}`
: `内容は「表示/開封」を押すと出ます。`
}
</div>
<div class="preview" id="prev_${item.id}"></div>
<div class="itemBtns">
<button class="ghost mini" data-act="show" data-id="${item.id}">表示</button>
<button class="ghost mini" data-act="open" data-id="${item.id}">開封</button>
<button class="ghost mini" data-act="copy" data-id="${item.id}">コピー</button>
<button class="danger mini" data-act="del" data-id="${item.id}">削除</button>
</div>
`;
// tag click => add filter
el.querySelectorAll(".tag").forEach(tagEl=>{
tagEl.addEventListener("click", ()=>{
const t = tagEl.getAttribute("data-tag");
if(!t) return;
activeTagFilters.add(t);
renderList();
showToast(`タグ絞り込み:${t}`);
});
});
// actions
el.querySelectorAll("button[data-act]").forEach(btn=>{
btn.addEventListener("click", ()=>{
const act = btn.getAttribute("data-act");
const id = btn.getAttribute("data-id");
if(act === "show") showItem(id);
if(act === "open") openItem(id);
if(act === "copy") copyItem(id);
if(act === "del") deleteItem(id);
});
});
list.appendChild(el);
});
}
function findItem(id){
const items = loadCapsules();
const idx = items.findIndex(x => x.id === id);
return { items, idx, item: idx >= 0 ? items[idx] : null };
}
function showItem(id){
const { item } = findItem(id);
if(!item) return;
const s = statusOf(item);
const prev = $("prev_" + id);
const box = $("box_" + id);
if(!prev || !box) return;
prev.style.display = "block";
if(s === "locked"){
prev.textContent = "🔒 まだ開封日じゃないので表示できません。";
showToast("まだ封印中");
return;
}
// openable or opened: show full text
prev.textContent = item.letter || "(手紙が空です)";
showToast("表示した");
}
function openItem(id){
const { items, idx, item } = findItem(id);
if(!item) return;
const s = statusOf(item);
if(s === "locked"){
showToast("まだ開封できない!");
showItem(id);
return;
}
// mark opened if not yet
if(!item.openedAt){
item.openedAt = todayYMD();
items[idx] = item;
saveCapsules(items);
}
showToast("開封した!");
renderList();
// after rerender, show immediately
setTimeout(()=>showItem(id), 0);
}
function copyToClipboard(text){
return navigator.clipboard.writeText(text).then(()=>true).catch(()=>{
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
return true;
});
}
function copyItem(id){
const { item } = findItem(id);
if(!item) return;
const s = statusOf(item);
if(s === "locked"){
showToast("封印中はコピー不可(開封日まで)");
return;
}
const text = item.letter || "";
if(!text){
showToast("コピーする内容がない");
return;
}
copyToClipboard(text).then(()=>{
showToast("コピーした!");
});
}
function deleteItem(id){
const { items, idx } = findItem(id);
if(idx < 0) return;
if(!confirm("この手紙を削除する?")) return;
items.splice(idx, 1);
saveCapsules(items);
showToast("削除した");
renderList();
}
// Export / Import
function exportJson(){
const items = loadCapsules();
const json = JSON.stringify(items, null, 2);
copyToClipboard(json).then(()=>{
showToast("手紙をコピーした(メモ帳に貼ったりして保管できます)");
});
}
function importJson(){
const txt = prompt("エクスポートした手紙を貼り付けてください(上書きではなく追加)");
if(!txt) return;
try{
const incoming = JSON.parse(txt);
if(!Array.isArray(incoming)) throw new Error("not array");
const current = loadCapsules();
const map = new Map(current.map(x=>[x.id,x]));
incoming.forEach(x=>{
// minimal validate
if(!x || typeof x !== "object") return;
if(!x.id) x.id = uid();
if(!x.createdAt) x.createdAt = todayYMD();
if(!x.futureDate) x.futureDate = todayYMD();
if(!x.tags) x.tags = [];
if(!x.letter) x.letter = "";
map.set(x.id, x);
});
saveCapsules([...map.values()]);
showToast("インポートした!");
renderList();
}catch(e){
alert("手紙の形式が正しくないかも。");
}
}
// Form reset
function resetForm(){
if(!confirm("入力をリセットする?(一覧は消えない)")) return;
meName.value = "";
mode.value = "encourage";
length.value = "normal";
titleEl.value = "";
tagsInput.value = "";
nowState.value = "";
goal.value = "";
oneAction.value = "";
setPreview(null);
showToast("入力リセット");
}
// Create / Add
function addSealedFromPreview(){
const fd = ensureFutureDate();
if(!fd) return;
const letter = (preview.textContent || "").trim();
if(!letter || preview.textContent === "ここにプレビューが出ます。"){
// generate if empty
const generated = buildLetter();
if(!generated) return;
setPreview(generated, "作成済み(未封印)");
}
const items = loadCapsules();
const item = {
id: uid(),
sealed: true,
openedAt: null,
createdAt: todayYMD(),
futureDate: fd,
meName: (meName.value || "俺").trim(),
mode: mode.value,
length: length.value,
title: (titleEl.value || "").trim(),
tags: normalizeTags(tagsInput.value),
nowState: (nowState.value || "").trim(),
goal: (goal.value || "").trim(),
oneAction: (oneAction.value || "").trim(),
letter: (preview.textContent || "").trim()
};
// If preview was placeholder, rebuild correctly
if(!item.letter || item.letter === "ここにプレビューが出ます。"){
const gen = buildLetter();
if(!gen) return;
item.letter = gen;
setPreview(gen, "作成済み(未封印)");
}
items.unshift(item);
saveCapsules(items);
showToast("封印して追加した!");
setBadge("封印して追加済み");
renderList();
}
// Tag chips in form
document.querySelectorAll(".chip").forEach(btn=>{
const tag = btn.getAttribute("data-tag");
const fillNow = btn.getAttribute("data-fill-now");
const fillGoal = btn.getAttribute("data-fill-goal");
const fillOne = btn.getAttribute("data-fill-one");
if(tag){
btn.addEventListener("click", ()=>{
const tags = normalizeTags(tagsInput.value);
if(tags.includes(tag)){
// remove
const next = tags.filter(t=>t!==tag);
tagsInput.value = next.join(", ");
showToast(`タグ削除:${tag}`);
}else{
tags.push(tag);
tagsInput.value = tags.slice(0,10).join(", ");
showToast(`タグ追加:${tag}`);
}
});
}
if(fillNow || fillGoal || fillOne){
btn.addEventListener("click", ()=>{
if(fillNow){
nowState.value = (nowState.value || "").trim()
? (nowState.value.trim() + "\n" + fillNow)
: fillNow;
nowState.focus();
}
if(fillGoal){
goal.value = fillGoal;
goal.focus();
}
if(fillOne){
oneAction.value = fillOne;
oneAction.focus();
}
});
}
});
// Wire buttons
$("create").addEventListener("click", ()=>{
const letter = buildLetter();
if(!letter) return;
setPreview(letter, "作成済み(未封印)");
showToast("作成した!");
});
$("sealAdd").addEventListener("click", addSealedFromPreview);
$("copyPreview").addEventListener("click", ()=>{
const text = (preview.textContent || "").trim();
if(!text || text === "ここにプレビューが出ます。"){
showToast("まず作成してね");
return;
}
copyToClipboard(text).then(()=>showToast("コピーした!"));
});
$("resetForm").addEventListener("click", resetForm);
// List toolbar events
[q, statusFilter, sort].forEach(el=>{
el.addEventListener("input", renderList);
el.addEventListener("change", renderList);
});
// Export / Import / Delete all
$("exportJson").addEventListener("click", exportJson);
$("importJson").addEventListener("click", importJson);
$("deleteAll").addEventListener("click", ()=>{
if(!confirm("本当に全削除する?(復元不可)")) return;
localStorage.removeItem(KEY);
activeTagFilters.clear();
showToast("全削除した");
renderList();
});
// Init: set default future date = +1 month
function init(){
const d = new Date();
d.setMonth(d.getMonth()+1);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,"0");
const day = String(d.getDate()).padStart(2,"0");
futureDate.value = `${y}-${m}-${day}`;
setPreview(null);
renderList();
}
init();
</script>
</body>
</html>

