SOURCE CODE

Apps Scriptへ登録するコード

次の6ファイルを、スプレッドシートから開いたApps Scriptへ貼り付けます。各「コードをコピー」ボタンは、表示を開かなくても全文をコピーできます。

公開用の初期コードです。個人の地点やカレンダーの情報は含まれていません。設定後のコードやデプロイしたURLを、公開の場へ転載しないでください。

1. サーバーコード

Apps Scriptのスクリプトファイルへ貼り付けます。表示名は Code.gs または コード.gs で構いません。冒頭の CONFIG で地域などを設定します。

Code.gs 343行 / 15.9 KB ファイルを保存
コード全文を表示
// =====================================================================
//  ネコ記録ダッシュボード  v1.0
//  配布元:合同会社PNY  https://www.pny.llc
//
//  スプレッドシートにバインドしたGoogle Apps Scriptとして使います
//  (スプレッドシートの「拡張機能 → Apps Script」)。
//
//  ページ(デプロイしたウェブアプリのURLに付けて開きます)
//    …/exec               ネコボタン(スマホ用)
//    …/exec?page=dash     ダッシュボード(タブレット用:天気・予定+ネコボタン)
//    …/exec?page=view     記録の一覧(PC用)
//    …/exec?page=settings ボタンの設定(PC用)
// =====================================================================

// ===== ここだけ自分用に書き換えてください =====
const CONFIG = {
  APP_NAME: 'ネコ記録',                   // 画面のタイトル(例:'ミケ記録')
  PLACE: '東京都千代田区',                 // 天気に表示する地名
  LAT: 35.6812, LON: 139.7671,            // 天気を取る場所の緯度・経度(Googleマップで右クリックすると出ます)
  WEATHER_LINK: 'https://tenki.jp/',      // 天気をタップしたときに開くページ(空欄なら開かない)
  CALENDAR_IDS: [],                       // 表示するカレンダーID。空なら表示中のすべてのカレンダー
};
// ============================================

const SHEET_NAME = 'log';
const HEADERS = ['日時', '行動', '日付', '詳細', 'ID'];
const TZ = 'Asia/Tokyo';
const PROP_KEY = 'BUTTONS';

// 線画アイコン(24×24、線のみ)。設定画面で選べる
const ICONS = {
  bowl:   { name: 'ごはん皿', svg: '<path d="M3 12h18a9 8 0 0 1-18 0z"/><path d="M7 20h10"/><path d="M8.5 8.5c0-1.2 1-1.6 1-2.8M12 8.5c0-1.2 1-1.6 1-2.8M15.5 8.5c0-1.2 1-1.6 1-2.8"/>' },
  fish:   { name: '魚',       svg: '<path d="M2.5 12c3.5-4.5 9.5-5 13.5 0c-4 5-10 4.5-13.5 0z"/><path d="M16 12l5-4v8z"/><circle cx="6.5" cy="11.2" r=".7" fill="currentColor"/>' },
  cup:    { name: 'コップ',   svg: '<path d="M5 3.5h14l-2 17H7z"/><path d="M5.8 10c2-1 4.1 1 6.2 0s4.2-1 6.2 0"/>' },
  yarn:   { name: '毛糸',     svg: '<circle cx="11" cy="11" r="7.5"/><path d="M5.5 6.5c4 1.5 7.5 5 8.5 11.5M4 12.5c4-1 8.5.5 12 4M9 3.8c2.5 2.5 4.5 6 5 10.5"/><path d="M16.5 16.5c1.5 1.5 2.5 3.5 4.5 3.5"/>' },
  pill:   { name: '薬',       svg: '<path d="M4.6 13.4l8.8-8.8a4.2 4.2 0 0 1 6 6l-8.8 8.8a4.2 4.2 0 0 1-6-6z"/><path d="M9 9l6 6"/>' },
  alert:  { name: '注意',     svg: '<path d="M12 3.5l9.5 16.5h-19z"/><path d="M12 10v4.5M12 17.3v.2"/>' },
  drop:   { name: 'しずく',   svg: '<path d="M12 3c3.2 4.3 6 7.6 6 11a6 6 0 0 1-12 0c0-3.4 2.8-6.7 6-11z"/><path d="M9.2 14.5a2.8 2.8 0 0 0 2.3 2.7"/>' },
  poop:   { name: 'うんち',   svg: '<path d="M4.5 20.5h15a2.2 2.2 0 0 0 0-4.4h-15a2.2 2.2 0 0 0 0 4.4z"/><path d="M6.5 16.1a2 2 0 0 1 0-4h11a2 2 0 0 1 0 4"/><path d="M8.8 12.1a1.9 1.9 0 0 1 0-3.8h6.4a1.9 1.9 0 0 1 0 3.8"/><path d="M10.5 8.3c0-2.2 1.2-3.6 3.3-4.6"/>' },
  heart:  { name: 'ハート',   svg: '<path d="M12 20s-8-4.8-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.2 12 20 12 20z"/>' },
  scale:  { name: '体重計',   svg: '<rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M8 9a5 5 0 0 1 8 0"/><path d="M12 9.5l1.5-2"/>' },
  brush:  { name: 'ブラシ',   svg: '<path d="M4 15h16v3a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><path d="M6 15V5M9 15V5M12 15V5M15 15V5M18 15V5"/>' },
  dot:    { name: '丸',       svg: '<circle cx="12" cy="12" r="7.5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/>' },
};
// アイコン未設定のボタンには名前から自動で割り当てる
const ICON_BY_LABEL = { 'ごはん': 'bowl', 'おやつ': 'fish', '水': 'cup', '遊び': 'yarn', '薬': 'pill', '吐いた': 'alert', 'おしっこ': 'drop', 'うんち': 'poop' };
const iconOf_ = b => (b.icon && ICONS[b.icon]) ? b.icon : (ICON_BY_LABEL[b.label] || 'dot');

// 初期設定(設定画面で保存すると、以後はそちらが使われる)
const DEFAULT_BUTTONS = [
  // 並び順:食べる系を上、排泄・嘔吐を下に離して配置
  { label: 'ごはん',   icon: 'bowl', color: '#f4a261', details: [['カリカリ', 'ウェット'], ['完食', '半分', '残した']] },
  { label: 'おやつ',   icon: 'fish', color: '#e9c46a', details: [['ちゅーる', 'カリカリ系', 'その他']] },
  { label: '水',       icon: 'cup', color: '#4fc3f7', details: [['少し', 'たくさん']] },
  { label: '遊び',     icon: 'yarn', color: '#81c784', details: [['短め', 'しっかり']] },
  { label: '薬',       icon: 'pill', color: '#ba68c8', details: [['薬1', '薬2']] },
  { label: '吐いた',   icon: 'alert', color: '#ef5350', details: [['毛玉', 'フード', '液体']] },
  { label: 'おしっこ', icon: 'drop', color: '#90caf9', details: [['普通', '少ない', '多い']] },
  { label: 'うんち',   icon: 'poop', color: '#a1887f', details: [['普通', 'ゆるい', '硬い', '血・異物']] },
];

function doGet(e) {
  const page = (e && e.parameter && e.parameter.page) || 'index';
  const file = { view: 'view', settings: 'settings', dash: 'dash' }[page] || 'index';
  const base = ScriptApp.getService().getUrl();
  const t = HtmlService.createTemplateFromFile(file);
  t.buttonsJson = JSON.stringify(getConfig());
  t.iconsJson = JSON.stringify(ICONS);
  t.weatherLinkJson = JSON.stringify(DASH.WEATHER_LINK);
  t.viewUrl = base + '?page=view';
  t.settingsUrl = base + '?page=settings';
  t.inputUrl = base;
  t.appName = CONFIG.APP_NAME;
  const n = CONFIG.APP_NAME;
  const titles = { index: n, view: n + ' 一覧', settings: n + ' 設定', dash: n + ' ダッシュボード' };
  return t.evaluate()
    .setTitle(titles[file])
    .addMetaTag('viewport', (file === 'index' || file === 'dash')
      ? 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'
      : 'width=device-width, initial-scale=1');
}

// ---- 設定 ----
function getConfig() {
  const s = PropertiesService.getScriptProperties().getProperty(PROP_KEY);
  const list = s ? JSON.parse(s) : DEFAULT_BUTTONS;
  return list.map(b => Object.assign({}, b, { icon: iconOf_(b) }));
}

function saveConfig(list) {
  if (!Array.isArray(list) || !list.length) throw new Error('ボタンが1つもありません');
  const seen = new Set();
  const clean = list.map(b => {
    const label = String(b.label || '').trim();
    if (!label) throw new Error('名前が空のボタンがあります');
    if (seen.has(label)) throw new Error('名前が重複しています:' + label);
    seen.add(label);
    const details = (b.details || [])
      .map(g => (g || []).map(o => String(o).trim()).filter(Boolean))
      .filter(g => g.length);
    return {
      label: label,
      icon: ICONS[b.icon] ? b.icon : iconOf_({ label: label }),
      color: /^#[0-9a-fA-F]{6}$/.test(b.color) ? b.color : '#cccccc',
      details: details,
    };
  });
  PropertiesService.getScriptProperties().setProperty(PROP_KEY, JSON.stringify(clean));
  return clean;
}

function resetConfig() {
  PropertiesService.getScriptProperties().deleteProperty(PROP_KEY);
  return getConfig();
}

// ---- 記録 ----
function getSheet_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sh = ss.getSheetByName(SHEET_NAME);
  if (!sh) {
    sh = ss.insertSheet(SHEET_NAME);
    sh.appendRow(HEADERS);
    sh.setFrozenRows(1);
  } else if (sh.getRange(1, 5).getValue() === '') {
    sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
  }
  return sh;
}

function withLock_(fn) {
  const lock = LockService.getScriptLock();
  lock.waitLock(5000);
  try { return fn(); } finally { lock.releaseLock(); }
}

const dayKey_ = d => Utilities.formatDate(d, TZ, 'yyyy-MM-dd');

function record(label) {
  const id = Utilities.getUuid().slice(0, 8);
  withLock_(() => {
    const now = new Date();
    getSheet_().appendRow([now, label, dayKey_(now), '', id]);
  });
  return { id: id, status: getStatus() };
}

function setDetail(id, detail) {
  withLock_(() => {
    const sh = getSheet_();
    const last = sh.getLastRow();
    if (last < 2) return;
    const start = Math.max(2, last - 199);
    const ids = sh.getRange(start, 5, last - start + 1, 1).getValues();
    for (let i = ids.length - 1; i >= 0; i--) {
      if (ids[i][0] === id) { sh.getRange(start + i, 4).setValue(detail); break; }
    }
  });
  return getStatus();
}

function undoLast() {
  withLock_(() => {
    const sh = getSheet_();
    const last = sh.getLastRow();
    if (last > 1) sh.deleteRow(last);
  });
  return getStatus();
}

// 入力画面用:設定・直近7日の日別回数(hist[label] = [6日前…今日])・最終時刻・最新記録
function getStatus() {
  const buttons = getConfig();
  const keys = [];
  for (let i = 6; i >= 0; i--) keys.push(dayKey_(new Date(Date.now() - i * 86400000)));
  const hist = {}, lastTime = {};
  buttons.forEach(b => hist[b.label] = [0, 0, 0, 0, 0, 0, 0]);
  let latest = null;

  const sh = getSheet_();
  const last = sh.getLastRow();
  if (last > 1) {
    const start = Math.max(2, last - 999);
    const rows = sh.getRange(start, 1, last - start + 1, 4).getValues();
    rows.forEach(r => {
      const ts = new Date(r[0]), label = r[1];
      const idx = keys.indexOf(dayKey_(ts));
      if (idx >= 0 && hist[label]) hist[label][idx]++;
      lastTime[label] = Utilities.formatDate(ts, TZ, 'M/d HH:mm');
    });
    const r = rows[rows.length - 1];
    latest = { label: r[1], time: Utilities.formatDate(new Date(r[0]), TZ, 'HH:mm'), detail: r[3] };
  }
  return { buttons, hist, lastTime, latest };
}

// 一覧画面用:直近days日分(新しい順)
function getLog(days) {
  const sh = getSheet_();
  const last = sh.getLastRow();
  if (last < 2) return [];
  const since = Date.now() - days * 86400000;
  const start = Math.max(2, last - 4999);
  const rows = sh.getRange(start, 1, last - start + 1, 4).getValues();
  const out = [];
  for (let i = rows.length - 1; i >= 0; i--) {
    const t = new Date(rows[i][0]).getTime();
    if (isNaN(t) || !rows[i][1]) continue;     // 空行や日時が壊れた行は飛ばす
    if (t < since) break;
    // 受け渡しで崩れないよう、数値と文字列だけにする
    out.push({ t: t, label: String(rows[i][1]), detail: rows[i][3] ? String(rows[i][3]) : '' });
  }
  return JSON.stringify(out);
}

// =====================================================================
// ダッシュボード(?page=dash):天気(MET Norway)と今日・明日の予定
// =====================================================================
const DASH = {
  PLACE: CONFIG.PLACE,
  LAT: CONFIG.LAT, LON: CONFIG.LON,
  WEATHER_LINK: CONFIG.WEATHER_LINK,
  USER_AGENT: 'pny-neko-dashboard/1.0 https://www.pny.llc',   // MET Norwayの利用規約でアプリ名と連絡先の明記が必須
  CALENDAR_IDS: CONFIG.CALENDAR_IDS,
  WEATHER_CACHE_SEC: 1800,
};

function getDashboard() {
  const out = { updated: Utilities.formatDate(new Date(), TZ, 'HH:mm') };
  try { out.weather = getWeather_(); } catch (e) { out.weatherError = e.message; }
  try { out.events = getEvents_(); } catch (e) { out.eventsError = e.message; }
  return out;
}

function wxText_(code, short) {
  if (!code) return '';
  const c = code.replace(/_(day|night|polartwilight)$/, '');
  if (short) {
    if (c.indexOf('thunder') >= 0) return '雷';
    if (c.indexOf('snow') >= 0 || c.indexOf('sleet') >= 0) return '雪';
    if (c.indexOf('rain') >= 0) return '雨';
    return { clearsky: '晴', fair: '晴', partlycloudy: '晴曇', cloudy: '曇', fog: '霧' }[c] || '';
  }
  if (c.indexOf('thunder') >= 0) return '雷雨';
  const map = {
    clearsky: '快晴', fair: '晴れ', partlycloudy: '晴れ時々曇り', cloudy: '曇り', fog: '霧',
    lightrain: '小雨', rain: '雨', heavyrain: '大雨',
    lightrainshowers: 'にわか雨', rainshowers: 'にわか雨', heavyrainshowers: '強いにわか雨',
    lightsnow: '小雪', snow: '雪', heavysnow: '大雪',
    lightsnowshowers: 'にわか雪', snowshowers: 'にわか雪', heavysnowshowers: '強いにわか雪',
  };
  if (map[c]) return map[c];
  return c.indexOf('sleet') >= 0 ? 'みぞれ' : c;
}

function getWeather_() {
  const cache = CacheService.getScriptCache();
  const hit = cache.get('wx_' + DASH.LAT + '_' + DASH.LON);
  if (hit) return JSON.parse(hit);

  const url = 'https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=' +
    DASH.LAT.toFixed(4) + '&lon=' + DASH.LON.toFixed(4);
  const res = UrlFetchApp.fetch(url, { headers: { 'User-Agent': DASH.USER_AGENT }, muteHttpExceptions: true });
  if (res.getResponseCode() !== 200) throw new Error('天気を取得できません(' + res.getResponseCode() + ')');
  const ts = JSON.parse(res.getContentText()).properties.timeseries;

  const now = new Date();
  const keys = [dayKey_(now), dayKey_(new Date(now.getTime() + 86400000))];
  const acc = {};
  keys.forEach(k => acc[k] = { max: -99, min: 99, pr: 0, sym: null, symH: 99 });

  ts.forEach(e => {
    const d = new Date(e.time), k = dayKey_(d), day = acc[k];
    if (!day) return;
    const h = Number(Utilities.formatDate(d, TZ, 'H'));
    const t = e.data.instant.details.air_temperature;
    day.max = Math.max(day.max, t);
    day.min = Math.min(day.min, t);
    const n1 = e.data.next_1_hours, n6 = e.data.next_6_hours;
    if (n1) day.pr += n1.details.precipitation_amount || 0;
    // 日中の天気:9時に近い時刻の6時間予報を代表にする
    if (n6 && Math.abs(h - 9) < Math.abs(day.symH - 9)) { day.sym = n6.summary.symbol_code; day.symH = h; }
  });

  const first = ts[0].data;
  const nowSym = (first.next_1_hours || first.next_6_hours || { summary: {} }).summary.symbol_code;
  const out = {
    place: DASH.PLACE,
    now: { t: Math.round(first.instant.details.air_temperature), s: wxText_(nowSym) },
    days: keys.map((k, i) => ({
      label: i === 0 ? '今日' : '明日',
      s: wxText_(acc[k].sym),
      max: acc[k].max > -99 ? Math.round(acc[k].max) : '-',
      min: acc[k].min < 99 ? Math.round(acc[k].min) : '-',
      pr: Math.round(acc[k].pr * 10) / 10,
    })),
    hours: [],
  };
  // これから12時間(3時間おき)
  for (let i = 0; i <= 12 && i < ts.length; i += 3) {
    const e = ts[i], n1 = e.data.next_1_hours;
    out.hours.push({
      h: Number(Utilities.formatDate(new Date(e.time), TZ, 'H')),
      t: Math.round(e.data.instant.details.air_temperature),
      s: wxText_(n1 && n1.summary.symbol_code, true),
    });
  }
  cache.put('wx_' + DASH.LAT + '_' + DASH.LON, JSON.stringify(out), DASH.WEATHER_CACHE_SEC);
  return out;
}

function getEvents_() {
  const cals = DASH.CALENDAR_IDS.length
    ? DASH.CALENDAR_IDS.map(id => CalendarApp.getCalendarById(id)).filter(Boolean)
    : CalendarApp.getAllCalendars().filter(c => !c.isHidden());
  const day = 86400000;
  const s0 = new Date(dayKey_(new Date()) + 'T00:00:00+09:00');
  const end = new Date(s0.getTime() + 2 * day);
  const out = [[], []];
  const hm = d => Utilities.formatDate(d, TZ, 'H:mm');

  cals.forEach(cal => {
    cal.getEvents(s0, end).forEach(ev => {
      const allDay = ev.isAllDayEvent(), st = ev.getStartTime(), en = ev.getEndTime();
      const eid = Utilities.base64Encode(ev.getId().replace(/@google\.com$/, '') + ' ' + cal.getId()).replace(/=+$/, '');
      for (let i = 0; i < 2; i++) {
        const ds = s0.getTime() + i * day, de = ds + day;
        if (!(st.getTime() < de && en.getTime() > ds)) continue;
        let time;
        if (allDay) time = '終日';
        else if (st.getTime() < ds) time = '〜' + (en.getTime() <= de ? hm(en) : '');
        else time = hm(st) + (en.getTime() <= de ? '–' + hm(en) : '〜');
        out[i].push({
          allDay: allDay, sort: allDay ? 0 : Math.max(st.getTime(), ds),
          time: time, title: ev.getTitle() || '(無題)', loc: ev.getLocation() || '',
          url: 'https://www.google.com/calendar/event?eid=' + eid,
        });
      }
    });
  });
  out.forEach(l => l.sort((a, b) => (b.allDay - a.allDay) || (a.sort - b.sort)));
  return { today: out[0], tomorrow: out[1] };
}

SHA-256: 71a6c0ef2d4f6454932437ed1ae95ca4a1f042751181f28e717dd38737fbc482

2. マニフェスト

[プロジェクトの設定]で「『appsscript.json』マニフェスト ファイルをエディタで表示する」をオンにし、表示された appsscript.json の中身と置き換えます。

appsscript.json 14行 / 0.4 KB ファイルを保存
コード全文を表示
{
  "timeZone": "Asia/Tokyo",
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets.currentonly",
    "https://www.googleapis.com/auth/calendar.readonly",
    "https://www.googleapis.com/auth/script.external_request"
  ],
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  }
}

SHA-256: 2ee52a8d84468546e019fd0aa4934f7bc0d85bd22c2397f8f6e575c5924b0b62

3. ネコボタン(スマホ用)

HTMLファイルを追加し、名前を必ず index にしてから貼り付けます。

index.html 262行 / 11.1 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body { margin: 0; height: 100%; }
  body { background: #f4f1ea; color: #111; overflow: hidden; -webkit-user-select: none; user-select: none;
         font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; }
  button { -webkit-appearance: none; font-family: inherit; cursor: pointer; }
  a { color: inherit; text-decoration: none; }

  #cat { display: flex; flex-direction: column; height: 100%; padding: 10px 12px; }
  #bar { display: flex; align-items: center; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1.5px solid #111; }
  #latest { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 15px; }
  .bb { flex: 0 0 auto; white-space: nowrap; border: 1.5px solid #111; background: #fff; color: #111; border-radius: 8px;
        padding: 7px 12px; font-size: 14px; margin-left: 8px; }
  #grid { display: flex; flex-wrap: wrap; border-top: 1.5px solid #111; border-left: 1.5px solid #111; }
  .btn { width: 50%; margin: 0; background: transparent; color: #111; text-align: left;
         border: none; border-right: 1.5px solid #111; border-bottom: 1.5px solid #111; border-radius: 0;
         display: flex; flex-direction: column; padding: 8px 12px 8px; overflow: hidden; }
  .btn:active { background: #e6e2d8; }
  .btn .ico { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; }
  .btn .ico svg { display: block; width: 48px; height: 48px; }
  .btn .lbl { text-align: center; font-size: 14px; font-weight: bold; white-space: nowrap; margin-bottom: 2px; }
  .btn .cnt { align-self: flex-end; line-height: 1; }
  .btn .cnt { font-size: 13px; color: #444; white-space: nowrap; }
  .btn .cnt b { font-size: 30px; color: #111; margin: 0 2px; }
  .btn .sub { font-size: 13px; color: #555; white-space: nowrap; }
  @media (orientation: landscape) { .btn { width: 25%; } }
  @media (max-height: 560px) { .btn .lbl { font-size: 12px; } .btn .cnt b { font-size: 19px; } .btn { padding: 6px 8px; } }

  /* 詳細パネル */
  #panel { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.45);
           display: none; align-items: center; justify-content: center; padding: 16px; }
  #panel.open { display: flex; }
  #card { background: #fff; border: 2px solid #111; border-radius: 16px; padding: 18px; width: 100%; max-width: 560px;
          max-height: 100%; overflow: auto; }
  #card h2 { margin: 0 0 4px; font-size: 22px; }
  .hint { font-size: 13px; color: #666; margin-bottom: 12px; }
  .group { display: flex; flex-wrap: wrap; margin: 0 -5px 8px; }
  .chip { flex: 1 1 auto; min-width: 90px; min-height: 54px; margin: 5px; border: 2px solid #111;
          border-radius: 12px; background: #fff; color: #111; font-size: 18px; }
  .chip.on { background: #111; color: #fff; font-weight: bold; }
  #close { width: 100%; min-height: 52px; border: 2px solid #111; border-radius: 12px; background: #f4f1ea;
           color: #111; font-size: 18px; margin-top: 4px; }
  #timer { height: 4px; background: #111; margin-top: 12px; width: 100%; }

  #toast { position: fixed; left: 50%; bottom: 28px; -webkit-transform: translateX(-50%); transform: translateX(-50%);
           background: #111; color: #fff; padding: 12px 24px; border-radius: 24px; font-size: 18px; white-space: nowrap;
           opacity: 0; -webkit-transition: opacity .3s; transition: opacity .3s; pointer-events: none; z-index: 10; }
  #toast.show { opacity: 1; }
  #toast.err { background: #a00; }
</style>
</head>
<body>
<div id="cat">
  <div id="bar">
    <span id="latest">読み込み中…</span>
    <a class="bb" href="<?= viewUrl ?>" target="_blank">一覧</a>
    <button class="bb" onclick="undo()">取り消し</button>
  </div>
  <div id="grid"></div>
</div>

<div id="panel" onclick="if (event.target === this) closePanel()">
  <div id="card">
    <h2 id="ptitle"></h2>
    <div class="hint">記録済みです。詳細は選ばなくてもOK</div>
    <div id="groups"></div>
    <button id="close" onclick="closePanel()">閉じる</button>
    <div id="timer"></div>
  </div>
</div>
<div id="toast"></div>

<script>
  // 古い端末でも動くようにES5で書いています(ダッシュボードと共通の作り)
  var BUTTONS = <?!= buttonsJson ?>;
  var ICONS = <?!= iconsJson ?>;
  var PANEL_SEC = 10;                 // 詳細パネルの表示秒数(触るたびに延長)
  var btnKey = JSON.stringify(BUTTONS);
  var cur = null, closeTimer = null, sendTimer = null;

  function $(id) { return document.getElementById(id); }
  function esc(s) {
    return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
    });
  }

  function iconSvg(name) {
    var ic = ICONS[name] || ICONS.dot;
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" ' +
           'stroke-linecap="round" stroke-linejoin="round">' + ic.svg + '</svg>';
  }

  function buildGrid() {
    var h = '', i, b;
    for (i = 0; i < BUTTONS.length; i++) {
      b = BUTTONS[i];
      h += '<button class="btn" onclick="tap(' + i + ')">' +
           '<span class="cnt" data-cnt="' + i + '">今日<b>0</b>回</span>' +
           '<span class="ico">' + iconSvg(b.icon) + '</span>' +
           '<span class="lbl">' + esc(b.label) + '</span>' +
           '<span class="sub" data-sub="' + i + '">最終 –</span></button>';
    }
    $('grid').innerHTML = h;
    layout();
  }

  // ボタンの高さを画面の残りに合わせる(縦は2列、横は4列)
  function layout() {
    var btns = $('grid').getElementsByTagName('button');
    var cols = window.innerWidth > window.innerHeight ? 4 : 2;
    var rows = Math.ceil(btns.length / cols) || 1;
    var avail = $('cat').clientHeight - 20 - $('bar').offsetHeight - 10;
    var h = Math.max(56, Math.floor((avail - 2) / rows));
    for (var i = 0; i < btns.length; i++) btns[i].style.height = h + 'px';
    var w = btns.length ? btns[0].clientWidth : 100;
    var size = Math.max(28, Math.min(Math.round(h * 0.5), Math.round(w * 0.45), 110));
    var svgs = $('grid').getElementsByTagName('svg');
    for (i = 0; i < svgs.length; i++) { svgs[i].style.width = size + 'px'; svgs[i].style.height = size + 'px'; }
  }


  function toast(msg, err) {
    var t = $('toast');
    t.textContent = msg;
    t.className = 'show' + (err ? ' err' : '');
    clearTimeout(t._h);
    t._h = setTimeout(function () { t.className = ''; }, 1500);
  }

  // 今日の記録なら時刻だけ("10/28 07:12" → "7:12")
  function shortTime(t) {
    if (!t) return '–';
    var d = new Date(), p = t.split(' ');
    if (p[0] === (d.getMonth() + 1) + '/' + d.getDate()) return p[1].replace(/^0/, '');
    return t;
  }

  function render(s) {
    var k = JSON.stringify(s.buttons);
    if (k !== btnKey && !cur) { BUTTONS = s.buttons; btnKey = k; buildGrid(); }   // 設定変更を反映

    $('latest').textContent = s.latest
      ? '最新:' + s.latest.label + (s.latest.detail ? '(' + s.latest.detail + ')' : '') + ' ' + s.latest.time
      : 'まだ記録なし';
    for (var i = 0; i < BUTTONS.length; i++) {
      var b = BUTTONS[i], h = s.hist[b.label] || [0, 0, 0, 0, 0, 0, 0];
      var cnt = document.querySelector('[data-cnt="' + i + '"]');
      if (cnt) cnt.innerHTML = '今日<b>' + h[6] + '</b>回';
      var sub = document.querySelector('[data-sub="' + i + '"]');
      if (sub) sub.textContent = '最終 ' + shortTime(s.lastTime[b.label]);
    }
  }

  function tap(i) {
    if (cur) closePanel();
    var b = BUTTONS[i];
    if (navigator.vibrate) navigator.vibrate(50);
    var c = cur = { btn: b, id: null, sel: [], dirty: false };
    for (var g = 0; g < b.details.length; g++) c.sel.push(null);
    if (b.details.length) openPanel(); else { toast('✓ ' + b.label); cur = null; }
    google.script.run
      .withSuccessHandler(function (res) { c.id = res.id; render(res.status); if (c.dirty) sendDetail(c); })
      .withFailureHandler(function (e) { toast('失敗:' + e.message, true); if (cur === c) hidePanel(); })
      .record(b.label);
  }

  function openPanel() {
    var b = cur.btn, h = '', gi, oi;
    $('ptitle').textContent = '✓ ' + b.label;
    for (gi = 0; gi < b.details.length; gi++) {
      h += '<div class="group">';
      for (oi = 0; oi < b.details[gi].length; oi++) {
        h += '<button class="chip" data-g="' + gi + '" data-o="' + oi + '" onclick="pick(' + gi + ',' + oi + ')">' +
             esc(b.details[gi][oi]) + '</button>';
      }
      h += '</div>';
    }
    $('groups').innerHTML = h;
    $('panel').className = 'open';
    resetTimer();
  }

  function pick(gi, oi) {
    var c = cur, opt = c.btn.details[gi][oi];
    c.sel[gi] = (c.sel[gi] === opt) ? null : opt;
    var chips = document.querySelectorAll('.chip[data-g="' + gi + '"]');
    for (var i = 0; i < chips.length; i++) {
      var on = c.btn.details[gi][+chips[i].getAttribute('data-o')] === c.sel[gi];
      chips[i].className = 'chip' + (on ? ' on' : '');
    }
    c.dirty = true;
    resetTimer();
    clearTimeout(sendTimer);
    sendTimer = setTimeout(function () { sendDetail(c); }, 600);
  }

  function selText(c) {
    var a = [];
    for (var i = 0; i < c.sel.length; i++) if (c.sel[i]) a.push(c.sel[i]);
    return a.join(' / ');
  }

  function sendDetail(c) {
    if (!c.id) return;                 // 記録完了後に送り直される
    c.dirty = false;
    google.script.run
      .withSuccessHandler(render)
      .withFailureHandler(function (e) { toast('詳細の保存に失敗:' + e.message, true); })
      .setDetail(c.id, selText(c));
  }

  function resetTimer() {
    clearTimeout(closeTimer);
    var bar = $('timer');
    bar.style.transition = bar.style.webkitTransition = 'none';
    bar.style.width = '100%';
    setTimeout(function () {
      bar.style.transition = bar.style.webkitTransition = 'width ' + PANEL_SEC + 's linear';
      bar.style.width = '0%';
    }, 30);
    closeTimer = setTimeout(closePanel, PANEL_SEC * 1000);
  }

  function closePanel() {
    if (!cur) return;
    var c = cur, d = selText(c);
    clearTimeout(sendTimer);
    if (c.dirty) sendDetail(c);
    toast('✓ ' + c.btn.label + (d ? '(' + d + ')' : ''));
    hidePanel();
  }

  function hidePanel() {
    clearTimeout(closeTimer);
    $('panel').className = '';
    cur = null;
  }

  function undo() {
    if (!confirm('直前の記録を取り消しますか?')) return;
    hidePanel();
    google.script.run
      .withSuccessHandler(function (s) { render(s); toast('取り消しました'); })
      .withFailureHandler(function (e) { toast('失敗:' + e.message, true); })
      .undoLast();
  }

  function refresh() { google.script.run.withSuccessHandler(render).getStatus(); }

  buildGrid();
  refresh();
  setInterval(refresh, 5 * 60 * 1000);   // 他の端末の記録・設定変更を5分ごとに反映
  window.addEventListener('resize', function () { setTimeout(layout, 200); });
  document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
</script>
</body>
</html>

SHA-256: d10248c3ad4ddec01e9f2c8a4922cfc0957bc525c72821bad9249b52d0a45137

4. ダッシュボード(タブレット用)

HTMLファイルを追加し、名前を必ず dash にしてから貼り付けます。

dash.html 370行 / 15.6 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body { margin: 0; height: 100%; }
  body { background: #f4f1ea; color: #111; overflow: hidden; -webkit-user-select: none; user-select: none;
         font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; }
  button { -webkit-appearance: none; font-family: inherit; cursor: pointer; }
  a { color: inherit; text-decoration: none; }

  #app { display: flex; height: 100%; }
  #info { flex: 0 0 44%; display: flex; flex-direction: column; min-height: 0;
          padding: 18px 22px 10px; border-right: 2px solid #111; }
  #cat { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 12px 14px; }
  @media (orientation: portrait) {
    #app { flex-direction: column; }
    #info { flex: 0 0 46%; border-right: none; border-bottom: 2px solid #111; }
  }

  /* 時計 */
  #date { font-size: 18px; }
  #time { font-size: 60px; font-weight: bold; line-height: 1.05; letter-spacing: 1px; }
  .sec { border-top: 1.5px solid #111; margin-top: 12px; padding-top: 10px; }
  .h { font-size: 13px; color: #555; margin-bottom: 6px; }

  /* 天気 */
  .wrow { display: flex; }
  .wday { flex: 1; }
  .wday .l { font-size: 14px; color: #555; }
  .wday .s { font-size: 22px; font-weight: bold; }
  .wday .t { font-size: 17px; }
  .wday .p { font-size: 13px; color: #555; }
  .hours { display: flex; margin-top: 8px; padding-top: 6px; border-top: 1px dotted #999; }
  .hours div { flex: 1; text-align: center; font-size: 12px; line-height: 1.35; color: #444; }
  .hours b { font-size: 16px; color: #111; }

  /* 予定 */
  #sched { flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; }
  .dh { font-size: 15px; font-weight: bold; margin: 4px 0 2px; }
  .dh + .dh, .ev + .dh, .none + .dh { margin-top: 12px; }
  .ev { display: flex; padding: 6px 0; border-bottom: 1px solid #d8d4ca; font-size: 16px; }
  .ev .et { flex: 0 0 108px; font-size: 14px; color: #333; padding-top: 2px; }
  .ev .en { flex: 1 1 auto; min-width: 0; }
  .ev .el { display: block; font-size: 12px; color: #666; }
  .none { font-size: 14px; color: #888; padding: 4px 0; }
  .err { font-size: 13px; color: #a00; }
  #upd { font-size: 11px; color: #888; text-align: right; padding-top: 4px; }

  /* ネコボタン */
  #bar { display: flex; align-items: center; margin-bottom: 10px; }
  #latest { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 15px; }
  .bb { flex: 0 0 auto; white-space: nowrap; border: 1.5px solid #111; background: #fff; color: #111; border-radius: 8px;
        padding: 7px 12px; font-size: 14px; margin-left: 8px; }
  #grid { display: flex; flex-wrap: wrap; border-top: 1.5px solid #111; border-left: 1.5px solid #111; }
  .btn { width: 50%; margin: 0; background: transparent; color: #111; text-align: left;
         border: none; border-right: 1.5px solid #111; border-bottom: 1.5px solid #111; border-radius: 0;
         display: flex; flex-direction: column; padding: 8px 12px 8px; overflow: hidden; }
  .btn:active { background: #e6e2d8; }
  .btn .ico { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; }
  .btn .ico svg { display: block; width: 48px; height: 48px; }
  .btn .lbl { text-align: center; font-size: 14px; font-weight: bold; white-space: nowrap; margin-bottom: 2px; }
  .btn .cnt { align-self: flex-end; line-height: 1; }
  .btn .cnt { font-size: 13px; color: #444; white-space: nowrap; }
  .btn .cnt b { font-size: 30px; color: #111; margin: 0 2px; }
  .btn .sub { font-size: 13px; color: #555; white-space: nowrap; }

  /* 詳細パネル */
  #panel { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.45);
           display: none; align-items: center; justify-content: center; padding: 20px; }
  #panel.open { display: flex; }
  #card { background: #fff; border: 2px solid #111; border-radius: 16px; padding: 20px; width: 100%; max-width: 560px; }
  #card h2 { margin: 0 0 4px; font-size: 24px; }
  .hint { font-size: 13px; color: #666; margin-bottom: 12px; }
  .group { display: flex; flex-wrap: wrap; margin: 0 -5px 8px; }
  .chip { flex: 1 1 auto; min-width: 100px; min-height: 58px; margin: 5px; border: 2px solid #111;
          border-radius: 12px; background: #fff; color: #111; font-size: 19px; }
  .chip.on { background: #111; color: #fff; font-weight: bold; }
  #close { width: 100%; min-height: 54px; border: 2px solid #111; border-radius: 12px; background: #f4f1ea;
           color: #111; font-size: 18px; margin-top: 4px; }
  #timer { height: 4px; background: #111; margin-top: 12px; width: 100%; }

  #toast { position: fixed; left: 50%; bottom: 28px; -webkit-transform: translateX(-50%); transform: translateX(-50%);
           background: #111; color: #fff; padding: 12px 24px; border-radius: 24px; font-size: 18px; white-space: nowrap;
           opacity: 0; -webkit-transition: opacity .3s; transition: opacity .3s; pointer-events: none; z-index: 10; }
  #toast.show { opacity: 1; }
  #toast.err { background: #a00; }
</style>
</head>
<body>
<div id="app">
  <div id="info">
    <div id="date"></div>
    <div id="time"></div>
    <div id="wx" class="sec" onclick="openUrl(WEATHER_LINK)"><div class="none">天気を読み込み中…</div></div>
    <div id="sched" class="sec"><div class="none">予定を読み込み中…</div></div>
    <div id="upd"></div>
  </div>
  <div id="cat">
    <div id="bar">
      <span id="latest">読み込み中…</span>
      <a class="bb" href="<?= viewUrl ?>" target="_blank">一覧</a>
      <a class="bb" href="<?= settingsUrl ?>" target="_blank">設定</a>
      <button class="bb" onclick="undo()">取り消し</button>
    </div>
    <div id="grid"></div>
  </div>
</div>

<div id="panel" onclick="if (event.target === this) closePanel()">
  <div id="card">
    <h2 id="ptitle"></h2>
    <div class="hint">記録済みです。詳細は選ばなくてもOK</div>
    <div id="groups"></div>
    <button id="close" onclick="closePanel()">閉じる</button>
    <div id="timer"></div>
  </div>
</div>
<div id="toast"></div>

<script>
  // 古いiPadのSafariでも動くようにES5で書いています
  var BUTTONS = <?!= buttonsJson ?>;
  var ICONS = <?!= iconsJson ?>;
  var WEATHER_LINK = <?!= weatherLinkJson ?>;
  var PANEL_SEC = 10;                 // 詳細パネルの表示秒数(触るたびに延長)
  var btnKey = JSON.stringify(BUTTONS);
  var cur = null, closeTimer = null, sendTimer = null, lastDay = null;

  function $(id) { return document.getElementById(id); }
  function pad(n) { return (n < 10 ? '0' : '') + n; }
  function esc(s) {
    return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
    });
  }
  function openUrl(u) { if (u) window.open(u, '_blank'); }

  // ================= 時計 =================
  function tick() {
    var d = new Date();
    $('date').textContent = (d.getMonth() + 1) + '月' + d.getDate() + '日(' + '日月火水木金土'.charAt(d.getDay()) + ')';
    $('time').textContent = d.getHours() + ':' + pad(d.getMinutes());
    if (lastDay !== null && lastDay !== d.getDate()) { loadDash(); refresh(); }   // 日付が変わったら更新
    lastDay = d.getDate();
  }

  // ================= 天気・予定 =================
  function loadDash() {
    google.script.run
      .withSuccessHandler(renderDash)
      .withFailureHandler(function (e) { $('upd').textContent = '更新に失敗:' + e.message; })
      .getDashboard();
  }

  function renderDash(d) {
    var h = '', i, x, w = d.weather;
    if (w) {
      h += '<div class="h">' + esc(w.place) + ' いま ' + w.now.t + '° ' + esc(w.now.s) + '</div><div class="wrow">';
      for (i = 0; i < w.days.length; i++) {
        x = w.days[i];
        h += '<div class="wday"><div class="l">' + x.label + '</div><div class="s">' + esc(x.s) + '</div>' +
             '<div class="t">' + x.max + '° / ' + x.min + '°</div><div class="p">降水 ' + x.pr + 'mm</div></div>';
      }
      h += '</div><div class="hours">';
      for (i = 0; i < w.hours.length; i++) {
        x = w.hours[i];
        h += '<div>' + x.h + '時<br><b>' + x.t + '°</b><br>' + esc(x.s) + '</div>';
      }
      h += '</div>';
    } else {
      h = '<div class="err">天気:' + esc(d.weatherError) + '</div>';
    }
    $('wx').innerHTML = h;

    $('sched').innerHTML = d.events
      ? dayBlock('今日', d.events.today) + dayBlock('明日', d.events.tomorrow)
      : '<div class="err">予定:' + esc(d.eventsError) + '</div>';
    $('upd').textContent = '更新 ' + d.updated + ' 天気データ:MET Norway';
  }

  function dayBlock(label, list) {
    var s = '<div class="dh">' + label + '</div>', i, x;
    if (!list.length) return s + '<div class="none">予定なし</div>';
    for (i = 0; i < list.length; i++) {
      x = list[i];
      s += '<a class="ev" href="' + esc(x.url) + '" target="_blank"><span class="et">' + esc(x.time) + '</span>' +
           '<span class="en">' + esc(x.title) + (x.loc ? '<span class="el">' + esc(x.loc) + '</span>' : '') + '</span></a>';
    }
    return s;
  }

  // ================= ネコボタン =================
  function iconSvg(name) {
    var ic = ICONS[name] || ICONS.dot;
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" ' +
           'stroke-linecap="round" stroke-linejoin="round">' + ic.svg + '</svg>';
  }

  function buildGrid() {
    var h = '', i, b;
    for (i = 0; i < BUTTONS.length; i++) {
      b = BUTTONS[i];
      h += '<button class="btn" onclick="tap(' + i + ')">' +
           '<span class="cnt" data-cnt="' + i + '">今日<b>0</b>回</span>' +
           '<span class="ico">' + iconSvg(b.icon) + '</span>' +
           '<span class="lbl">' + esc(b.label) + '</span>' +
           '<span class="sub" data-sub="' + i + '">最終 –</span></button>';
    }
    $('grid').innerHTML = h;
    layout();
  }

  // ボタンの高さを画面の残りに合わせる(古いSafariは%指定が効かないことがあるのでpxで)
  function layout() {
    var cat = $('cat'), btns = $('grid').getElementsByTagName('button');
    var rows = Math.ceil(btns.length / 2) || 1;
    var avail = cat.clientHeight - 24 - $('bar').offsetHeight - 10;
    var h = Math.max(60, Math.floor((avail - 2) / rows));
    for (var i = 0; i < btns.length; i++) btns[i].style.height = h + 'px';
    var w = btns.length ? btns[0].clientWidth : 100;
    var size = Math.max(28, Math.min(Math.round(h * 0.5), Math.round(w * 0.45), 110));
    var svgs = $('grid').getElementsByTagName('svg');
    for (i = 0; i < svgs.length; i++) { svgs[i].style.width = size + 'px'; svgs[i].style.height = size + 'px'; }
  }


  function toast(msg, err) {
    var t = $('toast');
    t.textContent = msg;
    t.className = 'show' + (err ? ' err' : '');
    clearTimeout(t._h);
    t._h = setTimeout(function () { t.className = ''; }, 1500);
  }

  // 今日の記録なら時刻だけ("10/28 07:12" → "7:12")
  function shortTime(t) {
    if (!t) return '–';
    var d = new Date(), p = t.split(' ');
    if (p[0] === (d.getMonth() + 1) + '/' + d.getDate()) return p[1].replace(/^0/, '');
    return t;
  }

  function render(s) {
    var k = JSON.stringify(s.buttons);
    if (k !== btnKey && !cur) { BUTTONS = s.buttons; btnKey = k; buildGrid(); }   // 設定変更を反映

    $('latest').textContent = s.latest
      ? '最新:' + s.latest.label + (s.latest.detail ? '(' + s.latest.detail + ')' : '') + ' ' + s.latest.time
      : 'まだ記録なし';
    for (var i = 0; i < BUTTONS.length; i++) {
      var b = BUTTONS[i], h = s.hist[b.label] || [0, 0, 0, 0, 0, 0, 0];
      var cnt = document.querySelector('[data-cnt="' + i + '"]');
      if (cnt) cnt.innerHTML = '今日<b>' + h[6] + '</b>回';
      var sub = document.querySelector('[data-sub="' + i + '"]');
      if (sub) sub.textContent = '最終 ' + shortTime(s.lastTime[b.label]);
    }
  }

  function tap(i) {
    if (cur) closePanel();
    var b = BUTTONS[i];
    var c = cur = { btn: b, id: null, sel: [], dirty: false };
    for (var g = 0; g < b.details.length; g++) c.sel.push(null);
    if (b.details.length) openPanel(); else { toast('✓ ' + b.label); cur = null; }
    google.script.run
      .withSuccessHandler(function (res) { c.id = res.id; render(res.status); if (c.dirty) sendDetail(c); })
      .withFailureHandler(function (e) { toast('失敗:' + e.message, true); if (cur === c) hidePanel(); })
      .record(b.label);
  }

  function openPanel() {
    var b = cur.btn, h = '', gi, oi;
    $('ptitle').textContent = '✓ ' + b.label;
    for (gi = 0; gi < b.details.length; gi++) {
      h += '<div class="group">';
      for (oi = 0; oi < b.details[gi].length; oi++) {
        h += '<button class="chip" data-g="' + gi + '" data-o="' + oi + '" onclick="pick(' + gi + ',' + oi + ')">' +
             esc(b.details[gi][oi]) + '</button>';
      }
      h += '</div>';
    }
    $('groups').innerHTML = h;
    $('panel').className = 'open';
    resetTimer();
  }

  function pick(gi, oi) {
    var c = cur, opt = c.btn.details[gi][oi];
    c.sel[gi] = (c.sel[gi] === opt) ? null : opt;
    var chips = document.querySelectorAll('.chip[data-g="' + gi + '"]');
    for (var i = 0; i < chips.length; i++) {
      var on = c.btn.details[gi][+chips[i].getAttribute('data-o')] === c.sel[gi];
      chips[i].className = 'chip' + (on ? ' on' : '');
    }
    c.dirty = true;
    resetTimer();
    clearTimeout(sendTimer);
    sendTimer = setTimeout(function () { sendDetail(c); }, 600);
  }

  function selText(c) {
    var a = [];
    for (var i = 0; i < c.sel.length; i++) if (c.sel[i]) a.push(c.sel[i]);
    return a.join(' / ');
  }

  function sendDetail(c) {
    if (!c.id) return;                 // 記録完了後に送り直される
    c.dirty = false;
    google.script.run
      .withSuccessHandler(render)
      .withFailureHandler(function (e) { toast('詳細の保存に失敗:' + e.message, true); })
      .setDetail(c.id, selText(c));
  }

  function resetTimer() {
    clearTimeout(closeTimer);
    var bar = $('timer');
    bar.style.transition = bar.style.webkitTransition = 'none';
    bar.style.width = '100%';
    setTimeout(function () {
      bar.style.transition = bar.style.webkitTransition = 'width ' + PANEL_SEC + 's linear';
      bar.style.width = '0%';
    }, 30);
    closeTimer = setTimeout(closePanel, PANEL_SEC * 1000);
  }

  function closePanel() {
    if (!cur) return;
    var c = cur, d = selText(c);
    clearTimeout(sendTimer);
    if (c.dirty) sendDetail(c);
    toast('✓ ' + c.btn.label + (d ? '(' + d + ')' : ''));
    hidePanel();
  }

  function hidePanel() {
    clearTimeout(closeTimer);
    $('panel').className = '';
    cur = null;
  }

  function undo() {
    if (!confirm('直前の記録を取り消しますか?')) return;
    hidePanel();
    google.script.run
      .withSuccessHandler(function (s) { render(s); toast('取り消しました'); })
      .withFailureHandler(function (e) { toast('失敗:' + e.message, true); })
      .undoLast();
  }

  function refresh() { google.script.run.withSuccessHandler(render).getStatus(); }

  // ================= 起動 =================
  tick();
  buildGrid();
  loadDash();
  refresh();
  setInterval(tick, 20 * 1000);
  setInterval(loadDash, 15 * 60 * 1000);   // 天気・予定は15分ごと
  setInterval(refresh, 5 * 60 * 1000);     // ネコボタンは5分ごと(他の端末の記録も反映)
  window.addEventListener('resize', function () { setTimeout(layout, 200); });
  document.addEventListener('visibilitychange', function () {
    if (!document.hidden) { tick(); loadDash(); refresh(); }   // スリープ復帰時
  });
</script>
</body>
</html>

SHA-256: ae73953936756c6a5b33988464efe0e4991a6a47b798b6352d01c60494b9a621

5. 一覧(PC用)

HTMLファイルを追加し、名前を必ず view にしてから貼り付けます。

view.html 184行 / 8.8 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: sans-serif; background: #f6f5f1; color: #222; }
  header { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #ddd;
           padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; z-index: 2; }
  header h1 { font-size: 18px; margin: 0 12px 0 0; }
  .seg button, .chip { border: 1px solid #ccc; background: #fff; border-radius: 8px;
                       padding: 6px 12px; font-size: 14px; cursor: pointer; }
  .seg button.on { background: #222; color: #fff; border-color: #222; }
  main { max-width: 1100px; margin: 0 auto; padding: 16px; }
  h2 { font-size: 16px; margin: 24px 0 10px; }
  #since { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .card { background: #fff; border-radius: 12px; padding: 12px 14px; border-left: 6px solid #ccc; }
  .card .l { font-size: 14px; color: #555; }
  .card .v { font-size: 22px; font-weight: bold; margin-top: 2px; }
  .card .s { font-size: 12px; color: #888; }
  #filters { display: flex; flex-wrap: wrap; gap: 6px; }
  .chip.off { opacity: .35; }
  .wrap { overflow-x: auto; background: #fff; border-radius: 12px; }
  table { border-collapse: collapse; width: 100%; font-size: 14px; }
  th, td { padding: 8px 10px; border-bottom: 1px solid #eee; text-align: center; white-space: nowrap; }
  th:first-child, td:first-child { text-align: left; }
  td.z { color: #ccc; }
  .day { background: #fff; border-radius: 12px; margin-bottom: 12px; overflow: hidden; }
  .day h3 { margin: 0; font-size: 14px; padding: 8px 14px; background: #eeece6; }
  .row { display: flex; gap: 12px; padding: 8px 14px; border-top: 1px solid #f0f0f0; font-size: 15px; }
  .row .t { width: 48px; color: #666; font-variant-numeric: tabular-nums; }
  .row .d { color: #666; }
  .empty { color: #888; padding: 16px; }
  .hrow { display: grid; grid-template-columns: 96px repeat(24, 1fr); gap: 2px; align-items: end;
          height: 56px; padding: 6px 10px; border-bottom: 1px solid #eee; min-width: 560px; }
  .hrow .n { align-self: center; font-size: 14px; white-space: nowrap; overflow: hidden; }
  .hb { border-radius: 2px 2px 0 0; min-height: 2px; }
  .hhead { height: auto; font-size: 11px; color: #888; align-items: center; border-bottom: none; }
  a.chip { text-decoration: none; color: #222; }
  .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
</style>
</head>
<body>
<header>
  <h1><?= appName ?></h1>
  <div class="seg" id="seg">
    <button data-d="1">今日</button><button data-d="7" class="on">7日</button><button data-d="30">30日</button>
  </div>
  <button class="chip" onclick="load()">更新</button>
  <a class="chip" href="<?= settingsUrl ?>">設定</a>
</header>
<main>
  <div id="msg" style="margin-top:12px;font-size:14px;color:#666"></div>
  <h2>最後の記録から</h2>
  <div id="since"></div>

  <h2>日別の回数</h2>
  <div id="filters"></div>
  <div class="wrap" style="margin-top:10px"><table id="table"></table></div>

  <h2>時間帯の傾向(選択期間・何時台に多いか)</h2>
  <div class="wrap"><div id="hours"></div></div>

  <h2>タイムライン</h2>
  <div id="timeline"></div>
</main>

<script>
  const BUTTONS = <?!= buttonsJson ?>;
  const COLOR = Object.fromEntries(BUTTONS.map(b => [b.label, b.color]));
  const DOT = l => `<span class="dot" style="background:${COLOR[l] || '#ccc'}"></span>`;
  let all = [], days = 7;
  const hidden = new Set();

  const pad = n => String(n).padStart(2, '0');
  const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; };
  const hm = t => { const d = new Date(t); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; };
  const md = t => { const d = new Date(t); return `${d.getMonth()+1}/${d.getDate()}`; };
  const wd = t => '日月火水木金土'[new Date(t).getDay()];
  function ago(t) {
    const m = Math.round((Date.now() - t) / 60000);
    if (m < 60) return `${m}分前`;
    const h = Math.floor(m / 60);
    return h < 48 ? `${h}時間${m % 60}分前` : `${Math.floor(h / 24)}日前`;
  }
  function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }

  document.querySelectorAll('#seg button').forEach(b => b.onclick = () => {
    document.querySelectorAll('#seg button').forEach(x => x.classList.remove('on'));
    b.classList.add('on'); days = +b.dataset.d; draw();
  });

  document.getElementById('filters').innerHTML = BUTTONS.map(b =>
    `<button class="chip" data-l="${b.label}">${DOT(b.label)}${esc(b.label)}</button>`).join('');
  document.querySelectorAll('#filters .chip').forEach(el => el.onclick = () => {
    const l = el.dataset.l;
    hidden.has(l) ? hidden.delete(l) : hidden.add(l);
    el.classList.toggle('off', hidden.has(l)); draw();
  });

  function load() {
    document.getElementById('msg').textContent = '読み込み中…';
    google.script.run
      .withSuccessHandler(res => {
        try {
          all = JSON.parse(res || '[]');
          document.getElementById('msg').textContent = all.length ? '' : '30日以内の記録がありません';
          draw();
        } catch (e) { showErr('表示エラー:' + e.message); }
      })
      .withFailureHandler(e => showErr('読み込みエラー:' + (e && e.message || e)))
      .getLog(30);
  }

  function showErr(m) {
    const el = document.getElementById('msg');
    el.textContent = m;
    el.style.color = '#c62828';
  }

  function draw() {
    // 最後の記録から(期間に関係なく30日分から)
    document.getElementById('since').innerHTML = BUTTONS.map(b => {
      const r = all.find(x => x.label === b.label);
      return `<div class="card" style="border-left-color:${b.color}">
        <div class="l">${esc(b.label)}</div>
        <div class="v">${r ? ago(r.t) : '—'}</div>
        <div class="s">${r ? md(r.t) + ' ' + hm(r.t) + (r.detail ? '・' + esc(r.detail) : '') : '30日以内なし'}</div></div>`;
    }).join('');

    // 期間で絞る(「今日」は今日の0時から)
    const now = new Date();
    const from = days === 1
      ? new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
      : new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1).getTime();
    const rows = all.filter(r => r.t >= from && !hidden.has(r.label));
    const labels = BUTTONS.map(b => b.label).filter(l => !hidden.has(l));

    // 日別の回数
    const dates = [];
    for (let i = 0; i < days; i++) dates.push(dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i)));
    const cnt = {};
    rows.forEach(r => { const k = dayKey(r.t); cnt[k] = cnt[k] || {}; cnt[k][r.label] = (cnt[k][r.label] || 0) + 1; });
    document.getElementById('table').innerHTML =
      `<tr><th>日付</th>${labels.map(l => `<th>${DOT(l)}${esc(l)}</th>`).join('')}</tr>` +
      dates.map(k => {
        const t = new Date(k + 'T00:00:00').getTime();
        return `<tr><td>${md(t)}(${wd(t)})</td>${labels.map(l => {
          const n = (cnt[k] || {})[l] || 0;
          return `<td class="${n ? '' : 'z'}">${n || '・'}</td>`;
        }).join('')}</tr>`;
      }).join('');

    // 時間帯ヒストグラム(項目ごとに最大値で正規化)
    let hh = `<div class="hrow hhead"><span class="n"></span>${
      Array.from({length: 24}, (_, h) => `<span>${h % 3 === 0 ? h : ''}</span>`).join('')}</div>`;
    labels.forEach(l => {
      const a = new Array(24).fill(0);
      rows.forEach(r => { if (r.label === l) a[new Date(r.t).getHours()]++; });
      const max = Math.max(1, ...a);
      hh += `<div class="hrow"><span class="n">${DOT(l)}${esc(l)}</span>${a.map((n, h) =>
        `<span class="hb" title="${h}時台 ${n}回" style="height:${n ? n / max * 100 : 0}%;background:${COLOR[l] || '#ccc'};${n ? '' : 'opacity:.25'}"></span>`
      ).join('')}</div>`;
    });
    document.getElementById('hours').innerHTML = hh;

    // タイムライン
    if (!rows.length) { document.getElementById('timeline').innerHTML = '<div class="empty">記録がありません</div>'; return; }
    const byDay = {};
    rows.forEach(r => (byDay[dayKey(r.t)] = byDay[dayKey(r.t)] || []).push(r));
    document.getElementById('timeline').innerHTML = Object.keys(byDay).map(k => {
      const t = new Date(k + 'T00:00:00').getTime();
      return `<div class="day"><h3>${md(t)}(${wd(t)})・${byDay[k].length}件</h3>${byDay[k].map(r =>
        `<div class="row"><span class="t">${hm(r.t)}</span>
          <span style="border-left:4px solid ${COLOR[r.label] || '#ccc'};padding-left:8px">${esc(r.label)}</span>
          <span class="d">${esc(r.detail)}</span></div>`).join('')}</div>`;
    }).join('');
  }

  load();
  setInterval(load, 5 * 60 * 1000);
</script>
</body>
</html>

SHA-256: 72fef5809a7b015031eed6070763f42216f842c918b5d7c074f7980fb87fb8ac

6. 設定(PC用)

HTMLファイルを追加し、名前を必ず settings にしてから貼り付けます。

settings.html 108行 / 5.9 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: sans-serif; background: #f6f5f1; color: #222; }
  header { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #ddd; z-index: 2;
           padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  header h1 { font-size: 18px; margin: 0 auto 0 0; }
  button, .link { border: 1px solid #ccc; background: #fff; border-radius: 8px; padding: 7px 12px;
                  font-size: 14px; cursor: pointer; color: #222; text-decoration: none; }
  button.primary { background: #222; color: #fff; border-color: #222; }
  button.danger { color: #c62828; }
  main { max-width: 760px; margin: 0 auto; padding: 16px; }
  .note { font-size: 13px; color: #666; line-height: 1.6; margin-bottom: 14px; }
  .item { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px;
          border-left: 8px solid #ccc; }
  .top { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  .top input[type=text] { font-size: 16px; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; }
  .top .name { flex: 1; min-width: 140px; }
  .top .emo { width: 56px; text-align: center; }
  .top input[type=color] { width: 44px; height: 36px; border: none; padding: 0; background: none; }
  .icoprev { display: inline-flex; width: 40px; height: 40px; align-items: center; justify-content: center; }
  .icosel { font-size: 15px; padding: 6px; border: 1px solid #ccc; border-radius: 6px; background: #fff; }
  .ops { margin-left: auto; display: flex; gap: 4px; }
  label { font-size: 13px; color: #555; }
  textarea { width: 100%; min-height: 70px; font-size: 15px; padding: 8px; border: 1px solid #ccc;
             border-radius: 6px; font-family: inherit; margin-top: 4px; }
  #toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: #2e7d32;
           color: #fff; padding: 10px 20px; border-radius: 20px; opacity: 0; transition: opacity .3s; }
  #toast.show { opacity: 1; }
  #toast.err { background: #c62828; }
</style>
</head>
<body>
<header>
  <h1>ボタン設定</h1>
  <a class="link" href="<?= viewUrl ?>">一覧へ</a>
  <button onclick="add()">ボタン追加</button>
  <button class="primary" onclick="save()">保存</button>
</header>
<main>
  <div class="note">
    詳細の選択肢は<b>1行=1グループ</b>、グループ内は「、」か「,」で区切ります(各グループから1つ選べる)。空欄なら詳細パネルは出ません。<br>
    ボタン名を変えると、過去の記録とは別項目として集計されます。入力端末には保存後5分以内に反映されます(すぐ反映したいときは再読み込み)。
  </div>
  <div id="list"></div>
  <button class="danger" onclick="reset()">初期設定に戻す</button>
</main>
<div id="toast"></div>

<script>
  let list = <?!= buttonsJson ?>;
  const ICONS = <?!= iconsJson ?>;
  const svg = n => `<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="#111" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${(ICONS[n] || ICONS.dot).svg}</svg>`;

  function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
  const toText = d => d.map(g => g.join('、')).join('\n');
  const fromText = t => t.split('\n').map(l => l.split(/[、,,]/).map(s => s.trim()).filter(Boolean)).filter(g => g.length);

  function draw() {
    document.getElementById('list').innerHTML = list.map((b, i) => `
      <div class="item" style="border-left-color:${b.color}">
        <div class="top">
          <input type="color" value="${b.color}" oninput="set(${i},'color',this.value);this.closest('.item').style.borderLeftColor=this.value">
          <span class=\"icoprev\">${svg(b.icon)}</span>
          <select class=\"icosel\" onchange=\"set(${i},'icon',this.value);this.previousElementSibling.innerHTML=svg(this.value)\">${Object.keys(ICONS).map(k => `<option value=\"${k}\" ${k === b.icon ? 'selected' : ''}>${ICONS[k].name}</option>`).join('')}</select>
          <input type="text" class="name" value="${esc(b.label)}" placeholder="ボタン名" oninput="set(${i},'label',this.value)">
          <div class="ops">
            <button onclick="move(${i},-1)" ${i === 0 ? 'disabled' : ''}>↑</button>
            <button onclick="move(${i},1)" ${i === list.length - 1 ? 'disabled' : ''}>↓</button>
            <button class="danger" onclick="del(${i})">削除</button>
          </div>
        </div>
        <label>詳細の選択肢
          <textarea oninput="set(${i},'details',fromText(this.value))" placeholder="例)カリカリ、ウェット&#10;完食、半分、残した">${esc(toText(b.details))}</textarea>
        </label>
      </div>`).join('');
  }

  function set(i, k, v) { list[i][k] = v; }
  function move(i, d) { const j = i + d; [list[i], list[j]] = [list[j], list[i]]; draw(); }
  function del(i) { if (confirm(`「${list[i].label}」を削除しますか?(過去の記録は残ります)`)) { list.splice(i, 1); draw(); } }
  function add() { list.push({ label: '', icon: 'dot', color: '#cfd8dc', details: [] }); draw();
                   document.querySelectorAll('.name')[list.length - 1].focus(); }

  function toast(m, err) {
    const t = document.getElementById('toast'); t.textContent = m; t.className = 'show' + (err ? ' err' : '');
    clearTimeout(t._h); t._h = setTimeout(() => t.className = '', 2000);
  }

  function save() {
    google.script.run
      .withSuccessHandler(l => { list = l; draw(); toast('保存しました'); })
      .withFailureHandler(e => toast(e.message, true))
      .saveConfig(list);
  }

  function reset() {
    if (!confirm('ボタン設定を初期状態に戻しますか?')) return;
    google.script.run.withSuccessHandler(l => { list = l; draw(); toast('初期設定に戻しました'); }).resetConfig();
  }

  draw();
</script>
</body>
</html>

SHA-256: 616fbb4d6ecc705e94b73509cb19e8669d4d4ffeac82436bbf7bf51c68260d4f