AI活用

【AIとつくってみた】やる気がある”今”を未来に届けるツールを作った話

shota

⬛︎夜中の「よし、やるぞ!」が翌朝には消えている案件

夜中にふと思いつくことがあります。

「明日から本気出す」 「よし、これやろう」 「今度こそ続けるぞ」
その瞬間は、確かに本気だった。なんなら眠れないほどに。

でも翌朝起きると、あの熱はどこへやら。
「まぁ、今日はいいか…」で終わってしまうことも多々あります。

でもこれ、私だけじゃないはずです。

⬛︎モチベーションは保存できない

お金みたいに貯金できたらいいのに…

でもモチベーションって、そうはいかないのです。

ある時は溢れるほどあって、 ない時は本当に何も湧いてこない。

そして厄介なことに、
「やる気がある時」と「実際に行動できる時」が、ズレる。

そこで私はこう思ったのです。

やる気がある”今”を、未来に残しておけないだろうか?

⬛︎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>

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

プロフィール
shota
shota
会社員 AI活用について日々奮闘中
こんにちは!shotaと申します。 ブログを見ていただきありがとうございます。 私自身が経験したこと、悩んで解決したことなどを記事にしております。特にAI活用について学び自分の時間をどのようにして増やすか?を追求しており、日々奮闘中です。私が学んだこと、考えていることを記事にしていきます。そんな記事が皆様のお役に立てれば光栄です。ぜひ、気になる記事があれば片っ端から読んでみてください!きっと明日の希望になるはず!!
記事URLをコピーしました