diff --git a/README.md b/README.md
index f70285d..d116b1e 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,7 @@ ICAL_TOKEN=生成したトークン文字列
- **更新頻度**: Pull 型のため、カレンダーアプリのポーリング間隔に依存します。Google Calendar・Outlook は約24時間、Apple カレンダーは約15分が目安です(リアルタイム更新は現行の Google Calendar 直接同期が適切)。
- **連続シフトの統合**: 同一ユーザーの連続する30分シフトは1つのイベントにまとめて表示されます(例: 13:00-13:30 + 13:30-14:00 → 13:00-14:00)。
- **特別シフトも含む**: 通常シフト(`shifts`)と特別シフト(`special_shifts`)の両方が含まれます。
+- **カレンダー名**: 購読するとカレンダーアプリ上に、個人用は「〇〇さんのシフト」(シフトが0件の場合は「マイシフト」)、全体用は「全員のシフト」という名前で追加されます。複数のカレンダーを購読しても区別できます。
- **セキュリティ**: URLに含まれるトークンが認証情報です。購読URLは他人に共有しないでください。漏洩した場合は `.env` の `ICAL_TOKEN` を再生成してサーバーを再起動すると、全ユーザーの購読URLが失効し、新しいURLが発行されます。
## 使用技術
diff --git a/help.html b/help.html
index a511d2f..3687ca2 100644
--- a/help.html
+++ b/help.html
@@ -541,7 +541,7 @@
設定の手順
6. 完了
-
左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。
+
左側のカレンダー一覧に「〇〇さんのシフト」(〇〇はあなたの名前)が追加され、自分のシフトが予定として表示されます。
diff --git a/js/modules/shifts.js b/js/modules/shifts.js
index 976dd0a..138247a 100644
--- a/js/modules/shifts.js
+++ b/js/modules/shifts.js
@@ -112,6 +112,13 @@ function displayCapacityOnAdminCalendar(capacityData) {
});
}
+// サーバーが返す「/api/...」形式のパスを、貼り付け可能な絶対URLに変換する
+// 本番は /shift2/ のようなサブパス配下で配信されるため、
+// window.location.origin ではなく config.API_BASE_URL を基準にする
+function toAbsoluteApiUrl(relativeUrl) {
+ return config.API_BASE_URL.replace(/\/api$/, '') + relativeUrl;
+}
+
// iCal購読URL(全シフト・管理者用)を入力欄にセットする
async function updateIcalUrl() {
const input = document.getElementById('icalAllUrl');
@@ -126,8 +133,7 @@ async function updateIcalUrl() {
try {
const result = await API.getAllIcalUrl(currentUser.email);
if (result.success && result.data && result.data.url) {
- // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする
- input.value = new URL(result.data.url, window.location.origin).href;
+ input.value = toAbsoluteApiUrl(result.data.url);
} else {
input.value = '(購読URLを取得できませんでした)';
}
@@ -164,8 +170,7 @@ async function updateMyIcalUrl() {
try {
const result = await API.getMyIcalUrl(currentUser.sub);
if (result.success && result.data && result.data.url) {
- // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする
- input.value = new URL(result.data.url, window.location.origin).href;
+ input.value = toAbsoluteApiUrl(result.data.url);
} else {
input.value = '(購読URLを取得できませんでした)';
}
@@ -854,3 +859,10 @@ async function deleteMyShift(buttonElement, uuids, isSpecial) {
alert('シフト情報の取得に失敗しました。');
}
}
+
+// テストから実コードを検証できるようにするエクスポートガード
+// (ブラウザでは module が未定義のため no-op になる)
+// 方針: docs/refactoring/phase-1-test-foundation.md
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { toAbsoluteApiUrl };
+}
diff --git a/server/src/__tests__/ICalService.test.ts b/server/src/__tests__/ICalService.test.ts
index ea78418..c47cc23 100644
--- a/server/src/__tests__/ICalService.test.ts
+++ b/server/src/__tests__/ICalService.test.ts
@@ -152,6 +152,45 @@ describe('ICalService', () => {
});
});
+ describe('カレンダー名', () => {
+ test('個人用カレンダーには本人の名前が入る(誰のシフトか分かるように)', () => {
+ const mockShifts = [
+ { id: 1, uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也',
+ email: 'a@example.com', date: '2026-04-21', time_slot: '13:00-13:30',
+ calendar_event_id: null, created_at: '', updated_at: '' },
+ ];
+ (ShiftModel.getByUserId as jest.Mock).mockReturnValue(mockShifts);
+
+ const result = ICalService.generateForUser('user-1');
+
+ expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト');
+ });
+
+ test('名前が取得できない場合でも「マイシフト」として識別できる', () => {
+ const result = ICalService.generateForUser('user-1');
+
+ expect(result).toContain('X-WR-CALNAME:マイシフト');
+ });
+
+ test('特別シフトしかない場合も本人の名前が入る', () => {
+ const mockApps = [
+ { uuid: 'app-1', special_shift_uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也',
+ email: 'a@example.com', time_slot: '13:00-13:30', date: '2026-04-20', created_at: '', updated_at: '' },
+ ];
+ (SpecialShiftApplicationModel.getAllWithShiftInfo as jest.Mock).mockReturnValue(mockApps);
+
+ const result = ICalService.generateForUser('user-1');
+
+ expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト');
+ });
+
+ test('全体用カレンダーは全員分だと分かる名前にする', () => {
+ const result = ICalService.generateAll();
+
+ expect(result).toContain('X-WR-CALNAME:全員のシフト');
+ });
+ });
+
describe('generateForUser(userId)', () => {
test('特定ユーザーの特別シフト申請のみ含まれる', () => {
const mockApps = [
diff --git a/server/src/services/ICalService.ts b/server/src/services/ICalService.ts
index b89ae84..83165c4 100644
--- a/server/src/services/ICalService.ts
+++ b/server/src/services/ICalService.ts
@@ -71,7 +71,7 @@ export class ICalService {
* 同一ユーザーの連続するシフトはひとつのイベントにまとめる
*/
static generateAll(): string {
- const cal = ical({ name: 'シフト管理', timezone: TIMEZONE });
+ const cal = ical({ name: '全員のシフト', timezone: TIMEZONE });
addMergedShiftEvents(cal, ShiftModel.getAll());
addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(undefined));
@@ -84,10 +84,19 @@ export class ICalService {
* 同一ユーザーの連続するシフトはひとつのイベントにまとめる
*/
static generateForUser(userId: string): string {
- const cal = ical({ name: 'シフト管理', timezone: TIMEZONE });
+ const shifts = ShiftModel.getByUserId(userId);
+ const specialApps = SpecialShiftApplicationModel.getAllWithShiftInfo(userId);
- addMergedShiftEvents(cal, ShiftModel.getByUserId(userId));
- addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(userId));
+ // カレンダーアプリ上で「誰のシフトか」が分かるよう本人の名前を使う。
+ // シフトが1件もない場合は名前を取得できないため既定名にする。
+ const userName = shifts[0]?.user_name || specialApps[0]?.user_name;
+ const cal = ical({
+ name: userName ? `${userName}さんのシフト` : 'マイシフト',
+ timezone: TIMEZONE
+ });
+
+ addMergedShiftEvents(cal, shifts);
+ addMergedSpecialShiftApplicationEvents(cal, specialApps);
return cal.toString();
}
diff --git a/test/ical-url-subpath.test.js b/test/ical-url-subpath.test.js
new file mode 100644
index 0000000..5c4a6cd
--- /dev/null
+++ b/test/ical-url-subpath.test.js
@@ -0,0 +1,68 @@
+/**
+ * iCal購読URLのサブパス対応テスト
+ *
+ * 回帰防止: 本番は https://tukumana.si.aoyama.ac.jp/shift2/ のように
+ * サブパス配下で配信されている。
+ * window.location.origin を基準に絶対URLを組み立てると
+ * /shift2 が欠落し、購読URLが 404 になる。
+ *
+ * このテストは実コード(js/modules/shifts.js)を直接 import して検証する。
+ * 方針: docs/refactoring/phase-1-test-foundation.md
+ */
+
+const { toAbsoluteApiUrl } = require('../js/modules/shifts.js');
+
+describe('iCal購読URLのサブパス対応', () => {
+ afterEach(() => {
+ delete global.config;
+ });
+
+ describe('サブパス配下(本番相当: /shift2/)', () => {
+ beforeEach(() => {
+ global.config = { API_BASE_URL: 'https://tukumana.si.aoyama.ac.jp/shift2/api' };
+ });
+
+ test('購読URLに /shift2 が含まれる', () => {
+ const url = toAbsoluteApiUrl('/api/ical/user/112204345149724665942?token=abc');
+
+ expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/user/112204345149724665942?token=abc');
+ });
+
+ test('/shift2 が欠落しない(欠落すると本番で404になる)', () => {
+ const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc');
+
+ expect(url).toContain('/shift2/api/ical/');
+ expect(url).not.toBe('https://tukumana.si.aoyama.ac.jp/api/ical/user/u1?token=abc');
+ });
+
+ test('管理者用(全シフト)URLにも /shift2 が含まれる', () => {
+ const url = toAbsoluteApiUrl('/api/ical/all?token=admin');
+
+ expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/all?token=admin');
+ });
+
+ test('トークンのクエリが壊れない', () => {
+ const url = toAbsoluteApiUrl('/api/ical/user/u1?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY');
+
+ expect(url).toContain('?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY');
+ });
+ });
+
+ describe('ルート直下(ローカル開発相当)', () => {
+ beforeEach(() => {
+ global.config = { API_BASE_URL: 'http://localhost:3000/api' };
+ });
+
+ test('サブパスがない場合もそのまま組み立てられる', () => {
+ const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc');
+
+ expect(url).toBe('http://localhost:3000/api/ical/user/u1?token=abc');
+ });
+
+ test('余分なパスが混入しない', () => {
+ const url = toAbsoluteApiUrl('/api/ical/all?token=abc');
+
+ expect(url).toBe('http://localhost:3000/api/ical/all?token=abc');
+ });
+ });
+});