SOURCE CODE
Apps Scriptへ登録するコード
次の6ファイルを、スプレッドシートから開いたApps Scriptへ貼り付けます。各「コードをコピー」ボタンは、表示を開かなくても全文をコピーできます。
1. サーバーコード
Apps Scriptのスクリプトファイルへ貼り付けます。表示名は Code.gs または コード.gs で構いません。冒頭の CONFIG で地域などを設定します。
コード全文を表示
// =====================================================================
// ネコ記録ダッシュボード 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 の中身と置き換えます。
コード全文を表示
{
"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 にしてから貼り付けます。
コード全文を表示
<!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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 にしてから貼り付けます。
コード全文を表示
<!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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 にしてから貼り付けます。
コード全文を表示
<!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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 にしてから貼り付けます。
コード全文を表示
<!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 => ({'&':'&','<':'<','>':'>','"':'"'}[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="例)カリカリ、ウェット 完食、半分、残した">${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