Skip to content

Commit d22de93

Browse files
yn1323claude
andauthored
fix: マジックリンクトークン消費修正・ErrorBoundary追加・テスト修正 (#277)
* feat: シフト閲覧ページにErrorBoundaryを追加し無効セッション時の表示を改善 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: ダッシュボードテストのスタッフフィールド期待値にisOwnerを追加 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: difit-reviewスキルの設定を更新 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: マジックリンクトークンが既存セッション返却時に消費されない問題を修正 既存セッションを返す場合でもusedAtを設定し、トークンのワンタイム性を保証する。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: スタッフ提出画面のpencilデザインプロンプトを追加 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 09f5fed commit d22de93

File tree

8 files changed

+250
-6
lines changed

8 files changed

+250
-6
lines changed

.claude/skills/difit-review/SKILL.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ This skill launches a requested git diff in a viewer that is easy for humans to
1111
This comment mechanism is well suited for code review findings and code explanations.
1212
Before running commands, choose `<difit-command>` using the following rule:
1313

14-
- If `command -v difit` succeeds, use `difit`.
15-
- Otherwise, use `npx difit`.
14+
- use `npx difit`.
1615
- If falling back to `npx difit` would require network access in a sandboxed environment without network permission, request escalated permissions and user approval before running it.
1716

1817
## Steps

convex/_generated/ai/ai-files.state.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"convex-migration-helper",
99
"convex-performance-audit",
1010
"convex-quickstart",
11-
"convex-setup-auth"
11+
"convex-setup-auth",
12+
"difit-review"
1213
]
1314
}

convex/dashboard/queries.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ describe("dashboard/queries", () => {
206206
// shop に shiftStartTime 等が漏れていないこと
207207
expect(Object.keys(result?.shop ?? {})).toEqual(["name"]);
208208
// staffs に shopId, isDeleted が漏れていないこと
209-
expect(Object.keys(result?.staffs[0] ?? {}).sort()).toEqual(["_id", "email", "name"]);
209+
expect(Object.keys(result?.staffs[0] ?? {}).sort()).toEqual(["_id", "email", "isOwner", "name"]);
210210
});
211211
});
212212

convex/staffAuth/mutations.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const verifyToken = mutation({
3838
};
3939
}
4040

41-
// 既存の有効なセッションがあればそれを返す(usedAtは設定しない)
41+
// 既存の有効なセッションがあればそれを返す
4242
const existingSessions = await ctx.db
4343
.query("sessions")
4444
.withIndex("by_staffId_recruitmentId", (q) =>
@@ -49,6 +49,7 @@ export const verifyToken = mutation({
4949
const validSession = existingSessions.find((s) => s.expiresAt > Date.now());
5050

5151
if (validSession) {
52+
await ctx.db.patch(magicLink._id, { usedAt: Date.now() });
5253
return {
5354
status: "ok" as const,
5455
sessionToken: validSession.sessionToken,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# pencil.dev プロンプト:スタッフ提出画面
2+
3+
## 背景・要件
4+
5+
### この画面について
6+
YPSのMVP 3ページ構成の最後の1ページ。管理者(店長)が作成した「シフト募集」に対して、スタッフがシフト希望を提出する画面。
7+
8+
### ユーザー
9+
- 学生バイト、パート主婦、フリーター
10+
- **ほぼ確実にスマホ**から操作(PCは想定しない)
11+
- YPSにアカウントは持っていない。メールのマジックリンクからアクセス
12+
- 面倒だと出さない → 店長がLINEで催促 → YPSの価値が崩壊する
13+
- **この画面の体験がYPS全体の成否を決める**
14+
15+
### アクセス方法
16+
店長がシフト募集を作成 → スタッフにメール送信(マジックリンク付き) → スタッフがリンクをタップ → この画面に到達
17+
18+
### マジックリンク有効期限
19+
提出締切日まで有効。締切後はアクセスしても提出・修正不可。
20+
21+
### 画面の4状態
22+
スタッフが同じリンクを開いたとき、状態によって表示が変わる:
23+
- **A. 未提出+締切前** → 全日休みデフォルト、入力して提出
24+
- **B. 提出済み+締切前** → 前回の回答がプリフィル済み、修正して再提出可能
25+
- **C. 提出済み+締切後** → 前回の回答を表示、編集不可(閲覧のみ)
26+
- **D. 未提出+締切後** → 締切超過メッセージ、操作不可
27+
28+
### 提出後に修正できる理由
29+
修正不可だと「すみません水曜やっぱ無理です」→ LINEで店長に連絡 → 店長が手修正、となりExcel時代と変わらない。スタッフ自身で修正できることで店長の負担を減らす。
30+
31+
---
32+
33+
## 入力仕様
34+
35+
### 入力内容
36+
各日に対して:
37+
- **出勤OK** → 開始時間・終了時間(30分単位、selectで選択)
38+
- **出られない** → 休み
39+
40+
### デフォルト
41+
- **全日「休み」がデフォルト**
42+
- 理由①: 出し忘れたスタッフが「全日出勤OK」扱いになる事故を防ぐ
43+
- 理由②: 未提出=いないものとして扱える安全設計
44+
45+
### 時間の初期値
46+
出勤ONにしたとき、開始・終了時間はその店舗のシフト時間帯(例: 9:00〜22:00)がデフォルトで入る。大半のスタッフはデフォのまま or 微調整だけで済む。
47+
48+
### 全休み提出
49+
全日休みでも提出可能。「今週全部出られません」も立派な回答。田中さんにとって未提出と全休みは意味が違う。
50+
51+
### 対応期間
52+
募集期間は管理者が任意に設定するため、7日〜31日の可変。
53+
54+
---
55+
56+
## インタラクション設計
57+
58+
### カードUI
59+
- 日数分のカードが縦に並ぶ
60+
- 1カード = 1行固定高(休みでも出勤でも同じ高さ)
61+
- **レイアウトシフト(カード位置のガタつき)を絶対に起こさない**
62+
63+
### 出勤ON操作
64+
- 休みカードをタップ → 出勤ONになり、同じ行内にselectボックス(開始・終了)が出る
65+
- 1タップで完了。BottomSheetやモーダルは不要(selectボックスなのでキーボードも不要)
66+
67+
### 出勤→休みに戻す操作
68+
- 日付タップでは休みに戻らない(**一方通行**
69+
- カード右端の×ボタンをタップすることでのみ休みに戻る
70+
- 理由: 誤タップで入力した時間が消える事故を防ぐ(破壊的操作には摩擦を入れる)
71+
72+
### 提出完了
73+
- 提出ボタンタップ → 完了画面(チェックマークアイコン + 提出内容サマリー)
74+
- 「内容を修正する」ボタンで入力画面に戻れる
75+
76+
---
77+
78+
## 画面パターン(4 + 1 = 5画面)
79+
80+
### 画面1:未提出+締切前(状態A)
81+
82+
スマホレイアウト(幅375px程度)。
83+
84+
**ヘッダー**
85+
- 1行目(小さめ): 店舗名「居酒屋さくら」
86+
- 2行目(大きめ): 「シフト希望を提出」
87+
88+
**期間情報エリア**
89+
- 左: 募集期間「4/7 (月) 〜 4/13 (日)」+ 提出締切「提出締切: 4/4 (金)」
90+
- 右: ステータスバッジ「未提出」(warning系の色)
91+
92+
**カード一覧(7日分)**
93+
- 全カードが「休み」状態
94+
- 各カード: 左に日付+曜日、右に「休み」バッジ(グレー系)
95+
- 土曜はblue系テキスト、日曜はred系テキスト
96+
- **1枚だけ出勤ON状態にして、selectボックスと×ボタンが見える状態で描画**(操作後のイメージが分かるように)
97+
98+
**提出ボタン**
99+
- 「提出する」プライマリボタン(teal系)
100+
- ボタン下に補助テキスト「出勤する日をタップしてください」
101+
102+
### 画面2:提出済み+締切前(状態B)
103+
104+
画面1をベースに以下を変更:
105+
106+
- ステータスバッジ: 「提出済み」(success系の色)
107+
- カード一覧: 前回の提出内容がプリフィルされた状態(例: 3日分が出勤ON、4日分が休み)
108+
- 提出ボタン: 「修正して提出する」(teal系)
109+
- ボタン下の補助テキスト: なし or 「変更したい日をタップしてください」
110+
111+
### 画面3:提出済み+締切後(状態C)
112+
113+
画面2をベースに以下を変更:
114+
115+
- ステータスバッジ: 「提出済み」(success系)
116+
- カード一覧: 前回の提出内容を表示、**selectなし、×ボタンなし、タップ不可**
117+
- 出勤日: 日付 + 時間テキスト(例「9:00 〜 18:00」teal系テキスト)
118+
- 休み日: 日付 + 「休み」バッジ
119+
- 提出ボタン: なし
120+
- 代わりに上部にインフォメッセージ「提出締切を過ぎたため変更できません」(info系の色)
121+
122+
### 画面4:未提出+締切後(状態D)
123+
124+
最もシンプルな画面。
125+
126+
**ヘッダー**: 画面1と同じ
127+
128+
**コンテンツ**
129+
- 中央にアイコン(時計やカレンダーなど)
130+
- メッセージ「提出締切を過ぎています」
131+
- サブテキスト「シフトの希望がある場合は、お店に直接ご連絡ください。」
132+
- カードなし、ボタンなし
133+
134+
### 画面5:提出完了画面
135+
136+
提出ボタンを押した直後の画面。
137+
138+
**ヘッダー**: 画面1と同じ
139+
140+
**コンテンツ(中央寄せ)**
141+
- チェックマークアイコン(teal系の丸背景 + 白チェック)
142+
- 「提出しました」テキスト(大きめ)
143+
- 「シフトが確定したらメールでお知らせします」サブテキスト
144+
145+
**提出内容サマリー(カード形式)**
146+
- 全日分の日付と時間 or 休みを一覧表示
147+
- 出勤日は時間をteal系テキストで表示
148+
- 休みはグレー系テキスト
149+
150+
**ボタン**
151+
- 「内容を修正する」アウトラインボタン
152+
153+
---
154+
155+
## 共通指示
156+
- カラー: プライマリ teal系、warning amber/yellow系、danger red系、info blue系
157+
- フォント: 日本語対応
158+
- **スマホレイアウトのみ(幅375px程度)**。PC版は不要
159+
- 各画面をフレーム分けして並べる
160+
- カードの高さは状態によらず統一されていること(レイアウトシフト防止のため)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Text } from "@chakra-ui/react";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { ErrorBoundary } from ".";
4+
5+
function ThrowingComponent({ message }: { message: string }): never {
6+
throw new Error(message);
7+
}
8+
9+
const meta = {
10+
title: "ui/ErrorBoundary",
11+
component: ErrorBoundary,
12+
} satisfies Meta<typeof ErrorBoundary>;
13+
export default meta;
14+
15+
export const NoError: StoryObj<typeof meta> = {
16+
args: {
17+
fallback: <Text>エラーが発生しました</Text>,
18+
children: <Text>正常にレンダリングされています</Text>,
19+
},
20+
};
21+
22+
export const WithStaticFallback: StoryObj<typeof meta> = {
23+
args: {
24+
fallback: <Text color="red.500">エラーが発生しました。再度お試しください。</Text>,
25+
children: <ThrowingComponent message="Test error" />,
26+
},
27+
};
28+
29+
export const WithRenderFunctionFallback: StoryObj<typeof meta> = {
30+
args: {
31+
fallback: (error: Error) => <Text color="red.500">エラー: {error.message}</Text>,
32+
children: <ThrowingComponent message="Something went wrong" />,
33+
},
34+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Component, type ErrorInfo, type ReactNode } from "react";
2+
3+
type Props = {
4+
children: ReactNode;
5+
fallback: ReactNode | ((error: Error) => ReactNode);
6+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
7+
};
8+
9+
type State = { error: Error | null };
10+
11+
export class ErrorBoundary extends Component<Props, State> {
12+
state: State = { error: null };
13+
14+
static getDerivedStateFromError(error: Error): State {
15+
return { error };
16+
}
17+
18+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
19+
this.props.onError?.(error, errorInfo);
20+
}
21+
22+
render(): ReactNode {
23+
if (this.state.error) {
24+
const { fallback } = this.props;
25+
return typeof fallback === "function" ? fallback(this.state.error) : fallback;
26+
}
27+
return this.props.children;
28+
}
29+
}

src/routes/_unregistered/shifts.view.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Id } from "@/convex/_generated/dataModel";
88
import { ExpiredView } from "@/src/components/features/StaffView/ExpiredView";
99
import { ShiftViewPage } from "@/src/components/features/StaffView/ShiftViewPage";
1010
import { StaffLayout } from "@/src/components/templates/StaffLayout";
11+
import { ErrorBoundary } from "@/src/components/ui/ErrorBoundary";
1112
import { FullPageSpinner } from "@/src/components/ui/FullPageSpinner";
1213

1314
type SessionInfo = {
@@ -38,6 +39,10 @@ function storeSession(recruitmentId: string, sessionToken: string): void {
3839
localStorage.setItem(`yps_session_${recruitmentId}`, JSON.stringify({ sessionToken, recruitmentId }));
3940
}
4041

42+
function clearSession(recruitmentId: string): void {
43+
localStorage.removeItem(`yps_session_${recruitmentId}`);
44+
}
45+
4146
export const Route = createFileRoute("/_unregistered/shifts/view")({
4247
validateSearch: (search: Record<string, unknown>) => ({
4348
token: (search.token as string) || undefined,
@@ -117,7 +122,22 @@ function ShiftViewRoute() {
117122
return <FullPageSpinner />;
118123
}
119124

120-
return <ShiftViewContent session={session} />;
125+
return (
126+
<ErrorBoundary
127+
fallback={
128+
<StaffLayout shopName="シフト閲覧">
129+
<ExpiredView recruitmentId={session.recruitmentId} />
130+
</StaffLayout>
131+
}
132+
onError={(error) => {
133+
if (error.message?.includes("ArgumentValidationError")) {
134+
clearSession(session.recruitmentId);
135+
}
136+
}}
137+
>
138+
<ShiftViewContent session={session} />
139+
</ErrorBoundary>
140+
);
121141
}
122142

123143
function ShiftViewContent({ session }: { session: SessionInfo }) {

0 commit comments

Comments
 (0)