みなさんRPGゲームって好きですか?
物語や冒険を通して主人公や仲間の成長を体験することができる類のゲームです。
私にとって、今回のアプリ開発は、それに近いと感じました。
一つひとつ加えたい機能を実現して理想の形に作り上げていく行程がとても楽しく取り組むことができています。
今回の記事は、タイトル通り、以前作成したWEBアプリカレンダーの続きになります。
目的:WEBアプリカレンダーをもっと使いやすくしよう!
目的のために、以下の目次の項目の機能を追加しました。
1.ファイルを指定フォルダにいれるだけで自動更新したい!
2.みんなの声を教えて!投稿フォームの組み込み
3.投稿コメントがメールで届いたらいいな!メールの自動送信
4.ロゴマークをHTML内に入れてしまおう!ロゴマークの入れ方
一歩ずつ成長.周りの意見を取り入れて改善をしよう
最後に.アプリ開発を通して
1.自動更新の方法
以前の開発では、Excelファイルをフォルダに入れてGoogleApps Scriptでデータの集約ボタンを押さなければ、新たなデータは集約されませんでしたが、指定フォルダにファイルを入れておくだけで、定時に自動更新をするように設定しました。
私の設定ではお昼1時、深夜1時の1日2回の更新にしてあります。
このおかげで、わざわざGoogleApps Scriptを開いて実行しなくても良くなり、他の方にも自信を持って使っていただけるようになりました。
以下データ自動更新部分のコードです。
データ自動更新コード
// =====================================
// データ集約 自動更新 単独版
// =====================================
//
// 【動作】
// 毎日 1時ごろ・13時ごろに
// importNewAndUpdatedAllocationFilesFromDrive()
// を自動実行します。
//
// ・新規ファイル → 追加
// ・更新ファイル → 差し替え
// ・削除済みファイル → 集約データから削除
// ・変更なし → スキップ
//
// 投稿メール通知機能は含みません。
// =====================================
const AUTO_AGGREGATION_ONLY = {
// 自動実行する関数
TRIGGER_FUNCTION_NAME: 'runAutoAggregationOnlyByTrigger',
// 実行時間
// 1 = 深夜1時
// 13 = 午後1時
TRIGGER_HOURS: [1, 13],
TRIGGER_NEAR_MINUTE: 0
};
// =====================================
// 自動更新を開始
// =====================================
/**
* 最初に1回だけ手動実行してください。
*
* 毎日1時ごろ・13時ごろの
* 自動集約トリガーを作成します。
*/
function startAutoAggregationOnly() {
// 同じトリガーが重複しないように
// 既存の自動集約トリガーを削除
stopAutoAggregationOnly(false);
const timezone = Session.getScriptTimeZone();
AUTO_AGGREGATION_ONLY.TRIGGER_HOURS.forEach(function(hour) {
ScriptApp
.newTrigger(
AUTO_AGGREGATION_ONLY.TRIGGER_FUNCTION_NAME
)
.timeBased()
.atHour(hour)
.nearMinute(
AUTO_AGGREGATION_ONLY.TRIGGER_NEAR_MINUTE
)
.everyDays(1)
.inTimezone(timezone)
.create();
});
autoAggregationOnlyAlert_(
'データ集約の自動更新を開始しました。\n\n' +
'毎日\n' +
'・1時ごろ\n' +
'・13時ごろ\n\n' +
'新規・更新・削除されたファイルを確認して、' +
'集約データを自動更新します。\n\n' +
'※Apps Scriptの仕様上、実行時刻は多少前後します。'
);
}
// =====================================
// 自動更新を停止
// =====================================
/**
* 自動集約を停止したい場合に手動実行します。
*/
function stopAutoAggregationOnly(showAlert) {
const triggers = ScriptApp.getProjectTriggers();
let deletedCount = 0;
triggers.forEach(function(trigger) {
if (
trigger.getHandlerFunction() ===
AUTO_AGGREGATION_ONLY.TRIGGER_FUNCTION_NAME
) {
ScriptApp.deleteTrigger(trigger);
deletedCount++;
}
});
if (showAlert !== false) {
autoAggregationOnlyAlert_(
'データ集約の自動更新を停止しました。\n\n' +
'削除したトリガー数:' +
deletedCount
);
}
return deletedCount;
}
// =====================================
// 今すぐ自動集約
// =====================================
/**
* トリガー時間を待たず、
* 今すぐ同じ処理を実行したい場合に使用します。
*/
function runAutoAggregationOnlyNow() {
const result =
runAutoAggregationOnlyCore_('手動実行');
autoAggregationOnlyAlert_(
'データ集約を実行しました。\n\n' +
'結果:' +
result.status +
'\n\n' +
result.message
);
return result;
}
// =====================================
// 時間主導トリガー用
// =====================================
/**
* Apps Scriptの時間主導トリガーから
* 自動的に呼び出されます。
*
* 手動実行は不要です。
*/
function runAutoAggregationOnlyByTrigger() {
return runAutoAggregationOnlyCore_(
'自動実行'
);
}
// =====================================
// 自動集約 本体
// =====================================
function runAutoAggregationOnlyCore_(mode) {
const startedAt = new Date();
try {
// -----------------------------
// 残った一時変換ファイルを整理
// -----------------------------
if (
typeof cleanupAllOrphanTemporaryConvertedFiles_ ===
'function'
) {
cleanupAllOrphanTemporaryConvertedFiles_(false);
}
// =================================
// 新規・更新・削除ファイルを確認
// =================================
if (
typeof importNewAndUpdatedAllocationFilesFromDrive !==
'function'
) {
throw new Error(
'importNewAndUpdatedAllocationFilesFromDrive が見つかりません。'
);
}
importNewAndUpdatedAllocationFilesFromDrive();
// -----------------------------
// Webアプリ起動用データを更新
// -----------------------------
if (
typeof prepareAppStartupDataSilent_ ===
'function'
) {
prepareAppStartupDataSilent_();
}
const message =
'新規・更新・削除ファイルの確認と、' +
'集約データの更新が完了しました。';
// -----------------------------
// 既存の自動運用ログがあれば記録
// -----------------------------
if (
typeof writeAutoLastResult_ ===
'function'
) {
writeAutoLastResult_(
'完了',
message
);
}
if (
typeof writeAutoRunLog_ ===
'function'
) {
writeAutoRunLog_(
mode,
'完了',
0,
message
);
}
return {
success: true,
status: '完了',
message: message,
startedAt: startedAt
};
} catch (error) {
const message =
error && error.message
? error.message
: String(error);
if (
typeof writeAutoLastResult_ ===
'function'
) {
writeAutoLastResult_(
'エラー',
message
);
}
if (
typeof writeAutoRunLog_ ===
'function'
) {
writeAutoRunLog_(
mode,
'エラー',
0,
message
);
}
console.log(
'自動集約エラー:' +
message
);
return {
success: false,
status: 'エラー',
message: message,
startedAt: startedAt
};
}
}
// =====================================
// 手動実行時のメッセージ表示
// =====================================
function autoAggregationOnlyAlert_(message) {
try {
SpreadsheetApp
.getUi()
.alert(message);
} catch (error) {
// 時間主導トリガーでは
// UIが使えないためログへ出力
console.log(message);
}
}
2.投稿フォームの組み込み
みなさんは日ごろ、社内ツールを使用して、もう少しこうしてほしいなーっと言った小さな不満があるかと思います。そのようなちょっとした要望だったり不具合を教えてもらえるように、投稿フォームを組み込みました。この投稿フォームでは、「要望」「不具合報告」「感想」の項目を選んで、一言でも気軽に書いていただけるように小窓で少し親しみやすいデザインにしました。
投稿フォームcode.gsコード
// =====================================
// ひとくち感想フォーム
// 単独組み込み版
// =====================================
const FEEDBACK_LOG_SHEET_NAME = '投稿ログ';
/**
* Webアプリから投稿された内容を
* 「投稿ログ」シートへ保存します。
*
* 手動実行不要
*/
function appSubmitFeedback(payload) {
const data = payload || {};
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet =
ss.getSheetByName(FEEDBACK_LOG_SHEET_NAME);
// 投稿ログがなければ自動作成
if (!sheet) {
sheet =
ss.insertSheet(FEEDBACK_LOG_SHEET_NAME);
}
// 見出しを作成
if (sheet.getLastRow() === 0) {
sheet
.getRange(1, 1, 1, 5)
.setValues([[
'投稿時間',
'属性',
'投稿メールアドレス',
'投稿コメント',
'通知済日時'
]]);
} else if (
String(
sheet.getRange('E1').getValue() || ''
).trim() !== '通知済日時'
) {
sheet
.getRange('E1')
.setValue('通知済日時');
}
// Googleアカウントのメールが
// 取得できる場合だけ記録
// 取得できない場合は空欄
let email = '';
try {
email =
Session
.getActiveUser()
.getEmail() || '';
} catch (error) {
email = '';
}
// 投稿内容を記録
sheet.appendRow([
new Date(),
String(data.type || ''),
email,
String(data.comment || ''),
''
]);
return {
success: true
};
}
投稿フォーム組み込みIndexコード
/* =====================================
ひとくち感想フォーム
===================================== */
.feedback-panel {
min-width: 0;
margin: 0;
padding: 8px 10px 7px;
border: 1px solid #dbe6f2;
border-radius: 12px;
background:
linear-gradient(
135deg,
#f7fbff 0%,
#fff8fb 100%
);
}
.feedback-heading {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 5px;
}
.feedback-heading h2 {
margin: 0;
color: #365b85;
font-size: 13px;
white-space: nowrap;
}
.feedback-heading p {
margin: 0;
color: #64748b;
font-size: 10px;
white-space: nowrap;
}
.feedback-form {
display: grid;
grid-template-columns:
100px minmax(190px, 1fr) 58px;
gap: 6px;
align-items: center;
}
.feedback-field {
display: block;
min-width: 0;
}
.feedback-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.feedback-field select,
.feedback-field textarea {
width: 100%;
height: 32px;
min-height: 32px;
padding: 5px 7px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-family:
"Meiryo UI",
Meiryo,
sans-serif;
font-size: 11px;
}
.feedback-field textarea {
max-height: 80px;
resize: none;
line-height: 1.35;
}
.feedback-submit-button {
height: 32px;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: #6c86a5;
color: #fff;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.feedback-submit-button:disabled {
opacity: .5;
cursor: wait;
}
.feedback-message {
min-height: 13px;
margin-top: 3px;
color: #166534;
font-size: 9px;
font-weight: 700;
}
.feedback-message.error-message {
color: #991b1b;
}
/* スマホ表示 */
@media (max-width: 680px) {
.feedback-heading {
align-items: center;
}
.feedback-heading p {
overflow: hidden;
text-overflow: ellipsis;
}
.feedback-form {
grid-template-columns:
92px minmax(0, 1fr) 54px;
}
}
3.自動メール送信
投稿していただいたコメントを毎日わくわくしながらコメントを確認していたのですが、あまり効率的ではないため、コメントが投稿された場合、翌日朝8時に自分のメールアドレスにコメントが送られるように設定しました。随時送る設定でも良いのですが、私以外の方が運用した時に、逐一メールが来るのも申し訳ないと思い定刻に送信されるように設定をしました。
自動メール送信コード
// =====================================
// 投稿ログ 自動メール送信
// 単独組み込み版
// =====================================
//
// 【設定】
// 設定!B9 = 通知先メールアドレス
//
// 【投稿ログ】
// A列:投稿時間
// B列:属性
// C列:投稿メールアドレス
// D列:投稿コメント
// E列:通知済日時
//
// 毎朝8時ごろ、E列が空欄の投稿だけ
// メール送信します。
// =====================================
const FEEDBACK_MAIL_CONFIG = {
SETTINGS_SHEET_NAME: '設定',
FEEDBACK_LOG_SHEET_NAME: '投稿ログ',
EMAIL_CELL: 'B9',
TRIGGER_FUNCTION_NAME:
'sendPendingFeedbackMail',
TRIGGER_HOUR: 8,
TIMEZONE: 'Asia/Tokyo'
};
// =====================================
// 最初に1回だけ実行
// =====================================
/**
* 毎朝8時ごろのメール送信を設定します。
*
* 最初に1回だけ手動実行してください。
*/
function setupFeedbackMailTrigger() {
const ss =
SpreadsheetApp.getActiveSpreadsheet();
const settingSheet =
ss.getSheetByName(
FEEDBACK_MAIL_CONFIG.SETTINGS_SHEET_NAME
);
if (!settingSheet) {
SpreadsheetApp
.getUi()
.alert(
'設定シートが見つかりません。'
);
return;
}
// B9の説明
settingSheet
.getRange('A9')
.setValue(
'投稿通知先メールアドレス'
);
const recipient =
normalizeFeedbackMailRecipient_(
settingSheet
.getRange(
FEEDBACK_MAIL_CONFIG.EMAIL_CELL
)
.getDisplayValue()
);
if (!recipient) {
SpreadsheetApp
.getUi()
.alert(
'設定シートB9に\n' +
'通知先メールアドレスを入力してください。'
);
return;
}
// 重複トリガー防止
removeFeedbackMailTriggers_();
ScriptApp
.newTrigger(
FEEDBACK_MAIL_CONFIG
.TRIGGER_FUNCTION_NAME
)
.timeBased()
.atHour(
FEEDBACK_MAIL_CONFIG
.TRIGGER_HOUR
)
.nearMinute(0)
.everyDays(1)
.inTimezone(
FEEDBACK_MAIL_CONFIG.TIMEZONE
)
.create();
SpreadsheetApp
.getUi()
.alert(
'投稿通知メールを設定しました。\n\n' +
'通知先:\n' +
recipient +
'\n\n毎朝8時ごろに確認します。\n' +
'未通知の投稿がある場合だけ、' +
'メールを送信します。'
);
}
// =====================================
// トリガー削除
// =====================================
function removeFeedbackMailTriggers_() {
const triggers =
ScriptApp.getProjectTriggers();
triggers.forEach(
function(trigger) {
if (
trigger.getHandlerFunction() ===
FEEDBACK_MAIL_CONFIG
.TRIGGER_FUNCTION_NAME
) {
ScriptApp.deleteTrigger(
trigger
);
}
}
);
}
// =====================================
// メール送信本体
// =====================================
/**
* 毎朝8時ごろ、
* 時間主導トリガーから自動実行されます。
*
* 手動実行は不要です。
*/
function sendPendingFeedbackMail() {
const ss =
SpreadsheetApp.getActiveSpreadsheet();
// -----------------------------
// 設定シート
// -----------------------------
const settingSheet =
ss.getSheetByName(
FEEDBACK_MAIL_CONFIG
.SETTINGS_SHEET_NAME
);
if (!settingSheet) {
console.log(
'設定シートが見つかりません。'
);
return {
sent: false,
count: 0
};
}
// -----------------------------
// 通知先
// -----------------------------
const recipient =
normalizeFeedbackMailRecipient_(
settingSheet
.getRange(
FEEDBACK_MAIL_CONFIG.EMAIL_CELL
)
.getDisplayValue()
);
if (!recipient) {
console.log(
'設定!B9にメールアドレスがありません。'
);
return {
sent: false,
count: 0
};
}
// -----------------------------
// 投稿ログ
// -----------------------------
const sheet =
ss.getSheetByName(
FEEDBACK_MAIL_CONFIG
.FEEDBACK_LOG_SHEET_NAME
);
if (
!sheet ||
sheet.getLastRow() <= 1
) {
console.log(
'投稿ログに通知対象がありません。'
);
return {
sent: false,
count: 0
};
}
// E列見出し
if (
String(
sheet
.getRange('E1')
.getValue() || ''
).trim()
!== '通知済日時'
) {
sheet
.getRange('E1')
.setValue(
'通知済日時'
);
}
const lastRow =
sheet.getLastRow();
const values =
sheet
.getRange(
2,
1,
lastRow - 1,
5
)
.getValues();
const pending = [];
// -----------------------------
// 未通知投稿を抽出
// -----------------------------
values.forEach(
function(row, index) {
const postedAt = row[0];
const notifiedAt = row[4];
// 投稿日時なし
if (!postedAt) {
return;
}
// すでに通知済み
if (notifiedAt) {
return;
}
pending.push({
sheetRow:
index + 2,
postedAt:
row[0],
type:
String(
row[1] || ''
).trim(),
email:
String(
row[2] || ''
).trim(),
comment:
String(
row[3] || ''
).trim()
});
}
);
// -----------------------------
// 未通知なし
// -----------------------------
if (
pending.length === 0
) {
console.log(
'未通知の投稿はありません。'
);
return {
sent: false,
count: 0
};
}
// =================================
// メール作成
// =================================
const subject =
'【店舗別 納品予定確認】' +
'新しい投稿があります(' +
pending.length +
'件)';
const plainBody =
buildFeedbackMailPlainBody_(
pending
);
const htmlBody =
buildFeedbackMailHtmlBody_(
pending
);
// =================================
// メール送信
// =================================
MailApp.sendEmail({
to:
recipient,
subject:
subject,
body:
plainBody,
htmlBody:
htmlBody,
name:
'店舗別 納品予定確認'
});
// =================================
// 送信済み記録
// =================================
const sentAt =
new Date();
const statusValues =
values.map(
function(row) {
return [
row[4] || ''
];
}
);
pending.forEach(
function(item) {
statusValues[
item.sheetRow - 2
][0] = sentAt;
}
);
sheet
.getRange(
2,
5,
statusValues.length,
1
)
.setValues(
statusValues
);
console.log(
'投稿通知メール送信完了:' +
pending.length +
'件'
);
return {
sent: true,
count:
pending.length,
recipient:
recipient
};
}
// =====================================
// メールアドレス整形
// =====================================
function normalizeFeedbackMailRecipient_(
value
) {
return String(
value || ''
)
.replace(
/[;;、\s]+/g,
','
)
.replace(
/,+/g,
','
)
.replace(
/^,|,$/g,
''
)
.trim();
}
// =====================================
// テキストメール作成
// =====================================
function buildFeedbackMailPlainBody_(
items
) {
const lines = [];
lines.push(
'店舗別 納品予定確認に' +
'新しい投稿があります。'
);
lines.push('');
lines.push(
'未通知件数:' +
items.length +
'件'
);
lines.push('');
items.forEach(
function(item, index) {
lines.push(
'------------------------------'
);
lines.push(
(index + 1) +
'件目'
);
lines.push(
'投稿時間:' +
formatFeedbackMailDate_(
item.postedAt
)
);
lines.push(
'属性:' +
(
item.type ||
'-'
)
);
lines.push(
'投稿者メール:' +
(
item.email ||
'未取得'
)
);
lines.push(
'内容:'
);
lines.push(
item.comment ||
'(コメントなし)'
);
lines.push('');
}
);
lines.push(
'※このメールは、' +
'設定シートB9に登録された宛先へ' +
'自動送信されています。'
);
return lines.join('\n');
}
// =====================================
// HTMLメール作成
// =====================================
function buildFeedbackMailHtmlBody_(
items
) {
let html =
'<div ' +
'style="' +
'font-family:Arial,Meiryo,sans-serif;' +
'color:#1f2937' +
'">';
html +=
'<h2 style="color:#2458a6">' +
'店舗別 納品予定確認:新しい投稿' +
'</h2>';
html +=
'<p>' +
'未通知件数:' +
'<strong>' +
items.length +
'件' +
'</strong>' +
'</p>';
items.forEach(
function(item, index) {
html +=
'<div style="' +
'margin:14px 0;' +
'padding:12px;' +
'border:1px solid #cbd5e1;' +
'border-radius:8px' +
'">';
html +=
'<div style="' +
'font-weight:bold;' +
'margin-bottom:6px' +
'">' +
(index + 1) +
'件目' +
'</div>';
html +=
'<div>' +
'投稿時間:' +
escapeFeedbackMailHtml_(
formatFeedbackMailDate_(
item.postedAt
)
) +
'</div>';
html +=
'<div>' +
'属性:' +
escapeFeedbackMailHtml_(
item.type ||
'-'
) +
'</div>';
html +=
'<div>' +
'投稿者メール:' +
escapeFeedbackMailHtml_(
item.email ||
'未取得'
) +
'</div>';
html +=
'<div style="' +
'margin-top:8px;' +
'font-weight:bold' +
'">' +
'内容' +
'</div>';
html +=
'<div style="' +
'white-space:pre-wrap' +
'">' +
escapeFeedbackMailHtml_(
item.comment ||
'(コメントなし)'
) +
'</div>';
html +=
'</div>';
}
);
html +=
'<p style="' +
'font-size:12px;' +
'color:#64748b' +
'">' +
'このメールは、' +
'設定シートB9に登録された宛先へ' +
'自動送信されています。' +
'</p>';
html +=
'</div>';
return html;
}
// =====================================
// 日時表示
// =====================================
function formatFeedbackMailDate_(
value
) {
try {
const date =
value instanceof Date
? value
: new Date(value);
if (
!isNaN(
date.getTime()
)
) {
return Utilities
.formatDate(
date,
FEEDBACK_MAIL_CONFIG
.TIMEZONE,
'yyyy/MM/dd HH:mm:ss'
);
}
} catch (error) {}
return String(
value || ''
);
}
// =====================================
// HTML特殊文字対策
// =====================================
function escapeFeedbackMailHtml_(
value
) {
return String(
value || ''
)
.replace(
/&/g,
'&'
)
.replace(
/</g,
'<'
)
.replace(
/>/g,
'>'
)
.replace(
/"/g,
'"'
)
.replace(
/'/g,
'''
);
}
// =====================================
// 今すぐテスト送信
// =====================================
/**
* 朝8時を待たずに、
* 未通知投稿があれば今すぐ送信します。
*/
function sendFeedbackMailNow() {
const result =
sendPendingFeedbackMail();
SpreadsheetApp
.getUi()
.alert(
result.sent
? '投稿通知メールを送信しました。\n\n' +
'送信件数:' +
result.count +
'件'
: '送信対象の投稿はありませんでした。'
);
}
4.マークの入れ方
私たちの仲間が、自分の制作物に名前やロゴをつけることで、より愛着が湧くということを教えていただきましたが、私は少し照れてしまうので、仲間たちのロゴを作りました!それを今回のアプリに加えさせていただいています。ロゴマークを加えるために、良い方法はないかなってAIさんに相談したら、どこかにアップロードをすることもなく、HTMLにBase64コードを組み込むことで表示ができると回答をいただきましたので、組み込んでみました。
ロゴマークの表示コード
<img
class="screen-brand-logo"
src="data:image/png;base64,ここにBase64コードを貼り付け"
alt="ロゴ"
>
一歩ずつ成長.周りの意見を取り入れて改善しよう
アプリをよりよくするためには、投稿ログのコメントや、同じ部署の方の意見を聞き、改善点を聞くことで、一歩ずつ理想のアプリを作り上げられるのではないかと思います。今回のアプリでも、多くの方に実際に触れていただき意見をもらうことで改善に繋げることができました。小さく作って動かしてみる。他の方に使ってもらうことで客観的な意見を多くの方からいただけます。実現できることとできないことがあるかもしれませんが、自分が思いもしなかった考え方が面白く感じます。
最後に.今回のアプリ開発を通して
アプリ開発をするにあたり、多くの方に教えていただき、助けてもらいました。今回の制作物は私たちの業務効率化に大きく寄与することから、社内の170人あまりが参加をするオンライン会議の場で発表をさせていただくことができました。それも助けてくれた仲間のおかげです。自分の部署の改善だけでなく、他の部署にも同様のアプリを作成させていただいて、多くの方に使っていただけるようになりました。それぞれの部署で元となるExcelファイルのフォーマットが違うため、新しく作り直す必要がありますが、ゲームのように楽しく取り組むことができています。私の力ではまだまだできることは少ないかもしれませんが、これからも様々なツール作りに挑んでみます。最後まで読んでいただいてありがとうございました。


