Skip to content

Commit 7657c01

Browse files
iakito-devclaude
andauthored
feat(feed): title the feed by the unread/read filter and category (#72)
Desktop heading, mobile sr-only h1 and the browser tab title now say 未読の記事 / 既読の記事 when the read filter is active, and the tab title names the selected category. Previously the heading stayed フィード. Claude-Session: https://claude.ai/code/session_01DwThJmLcy2KKjqvHXUX53z Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7cffab0 commit 7657c01

6 files changed

Lines changed: 76 additions & 6 deletions

File tree

‎app/(app)/page.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export async function generateMetadata({
9999
q: sp.q,
100100
tag,
101101
company: companyName,
102+
category: sp.category,
103+
read: sp.read,
104+
feed: sp.feed,
102105
sort: parseFeedSort(sp.sort),
103106
});
104107
return {

‎components/layout/feed-header-title.test.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,31 @@ describe('resolveFeedHeaderTitle', () => {
1010
expect(resolveFeedHeaderTitle({ sort: 'latest' })).toBe('新着');
1111
});
1212

13-
it('企業・既読などその他の条件は「フィード」を返す', () => {
13+
it('企業名を解決できない企業などその他の条件は「フィード」を返す', () => {
1414
expect(resolveFeedHeaderTitle({ company: 'acme' })).toBe('フィード');
15-
expect(resolveFeedHeaderTitle({ read: 'unread' })).toBe('フィード');
1615
expect(resolveFeedHeaderTitle({ sort: 'popular', company: 'acme' })).toBe('フィード');
17-
expect(resolveFeedHeaderTitle({ sort: 'popular', read: 'unread' })).toBe('フィード');
16+
expect(resolveFeedHeaderTitle({ feed: 'f1' })).toBe('フィード');
1817
expect(resolveFeedHeaderTitle({})).toBe('フィード');
1918
});
2019

20+
it('未読/既読の絞り込みは「未読の記事」/「既読の記事」を返す(並び替え・期間と併用しても)', () => {
21+
expect(resolveFeedHeaderTitle({ read: 'unread' })).toBe('未読の記事');
22+
expect(resolveFeedHeaderTitle({ read: ' read ' })).toBe('既読の記事');
23+
expect(resolveFeedHeaderTitle({ read: 'unread', sort: 'popular' })).toBe('未読の記事');
24+
expect(resolveFeedHeaderTitle({ read: 'read', sort: 'latest', period: 'week' })).toBe(
25+
'既読の記事',
26+
);
27+
});
28+
29+
it('未読/既読より category・tag・検索・企業名を優先し、マイフィード中・不正な値は「フィード」', () => {
30+
expect(resolveFeedHeaderTitle({ read: 'unread', category: 'ai' })).toBe('AI・機械学習');
31+
expect(resolveFeedHeaderTitle({ read: 'unread', tag: 'Go' })).toBe('#Go');
32+
expect(resolveFeedHeaderTitle({ read: 'unread', q: 'go' })).toBe('検索結果');
33+
expect(resolveFeedHeaderTitle({ read: 'unread', feed: 'f1' })).toBe('フィード');
34+
expect(resolveFeedHeaderTitle({ read: 'all' })).toBe('フィード');
35+
expect(resolveFeedHeaderTitle({ read: 'bogus' })).toBe('フィード');
36+
});
37+
2138
it('検索中は「検索結果」を返す', () => {
2239
expect(resolveFeedHeaderTitle({ q: 'nextjs' })).toBe('検索結果');
2340
expect(resolveFeedHeaderTitle({ q: 'nextjs', sort: 'popular' })).toBe('検索結果');

‎components/layout/feed-header-title.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export type FeedHeaderParams = {
1515
tag?: string | null;
1616
};
1717

18+
const READ_TITLES: Record<string, string | undefined> = {
19+
unread: '未読の記事',
20+
read: '既読の記事',
21+
};
22+
1823
const nonEmpty = (value: string | null | undefined): boolean => !!value?.trim();
1924

2025
/**
@@ -47,8 +52,9 @@ export function isFeedDefaultView(params: FeedHeaderParams): boolean {
4752
* - category 未指定 かつ 他の絞り込みも無く sort=latest だけが指定された「新着一覧」なら「新着」
4853
* - ?q= があれば「検索結果」
4954
* - ?company= が 1 社だけで、呼び出し側がその企業名を解決できていれば企業名
55+
* - ?read=unread / ?read=read なら「未読の記事」/「既読の記事」(マイフィード ?feed= で絞っているときは除く)
5056
* - sort=popular のみ(期間の指定は可)なら「人気の記事」
51-
* - それ以外(既定状態・企業・既読など、または不正な category キー)は「フィード」
57+
* - それ以外(既定状態・企業名を解決できない企業・マイフィードなど、または不正な category キー)は「フィード」
5258
*/
5359
export function resolveFeedHeaderTitle(
5460
params: FeedHeaderParams,
@@ -73,6 +79,12 @@ export function resolveFeedHeaderTitle(
7379
const companyName = options.companyName?.trim();
7480
if (companyName) return companyName;
7581

82+
// 未読/既読で絞った一覧。サイドバーで選んだ状態と見出しが食い違わないようにする
83+
// (以前は「フィード」のままで、全件を見ているのか未読だけなのか見出しから分からなかった)。
84+
// 値の解釈は parseReadMode と同じく 'unread' / 'read' 以外は無視する。
85+
const readMode = READ_TITLES[params.read?.trim() ?? ''];
86+
if (readMode && !nonEmpty(params.feed)) return readMode;
87+
7688
if (!hasOtherFilter(params) && params.sort?.trim() === 'latest') {
7789
return '新着';
7890
}

‎docs/features/feed.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
- 入力の角丸はアプリ共通で 14px(`rounded-md`)に揃える。共通 Input(デスクトップ検索・設定フォーム)と絞り込みシートの企業検索が 14px なので、モバイル検索バーの pill(`rounded-pill`)だけが外れ値だった。塗り・枠・影といった外枠の表現は各コンポーネントの既存のまま変えない(角丸だけ揃える)。絞り込みボタン(44px)も同じ 14px にする(完全な円だと検索バーの隣で独立した丸い島に見える)。ヘッダーのアイコンは 18px に統一する(虫眼鏡 16px / 絞り込み 20px で不揃いだった)。検索オーバーレイ(`MobileSearchOverlay`)と skeleton(`MobileFeedHeaderSkeleton`)も同じ寸法に揃える(skeleton がズレると読み込み前後で検索窓の見え方が変わる)
4242
- モバイル(<md)の左右ガターは `shared/layout.ts` の `MOBILE_GUTTER_CLASS`(20px / 430px 以上は 24px)に集約する。16px だと 440pt 級の端末で 18px の太字タイトルが画面端まで詰まって窮屈に見え、一方で小型端末(375pt)まで 24px にすると1行の文字数が減るため画面幅で段を付ける。sticky ヘッダー(検索行・チップ行)とリスト本体・棚・イベント・保存記事・設定・ロゴ帯が同じ定数を使う(ズレるとカードの上罫と検索バーの左端が揃わない)。md 以上(`md:px-6` / `lg:px-8`)は各画面が最大幅に応じて付ける
4343
- モバイル(<md)の検索行の左にはロゴマーク(`LogoMark`: ワードマーク無しの 2×2 ドット)を置く。フィードは共通 Header(ロゴ帯)を隠すため、これが無いと他タブ(ロゴ帯あり)から入った瞬間にロゴが消える。既にホームなので遷移先を持たない装飾(aria-hidden)として置き、行の高さは変えない
44-
- 絞り込み中のタブ/履歴タイトル(`feedPageTitle`): 検索語があれば「語」の検索結果、無ければタグ指定で #タグ の記事(検索優先 → タグ → 企業 → 人気順の順、40 文字で切る)。企業を 1 社だけ選んでいれば(`?company=slug`、複数選択は対象外)「企業名 の記事」、人気順(`?sort=popular`)のみなら「人気の記事」。それ以外は既定タイトルのまま。複数タブやブラウザ履歴で検索・タグ違いを見分けるため。canonical・noindex の扱いは変えない
44+
- 絞り込み中のタブ/履歴タイトル(`feedPageTitle`): 検索語があれば「語」の検索結果、無ければタグ指定で #タグ の記事(検索優先 → タグ → 企業 → 人気順の順、40 文字で切る)。企業を 1 社だけ選んでいれば(`?company=slug`、複数選択は対象外)「企業名 の記事」、有効なカテゴリ(`?category=`)なら「カテゴリ名 の記事」、未読/既読(`?read=unread|read`)なら「未読の記事」/「既読の記事」、人気順(`?sort=popular`)のみなら「人気の記事」。それ以外は既定タイトルのまま。複数タブやブラウザ履歴で検索・タグ違いを見分けるため。canonical・noindex の扱いは変えない
45+
- デスクトップの見出し(`FeedHeader` / `resolveFeedHeaderTitle`)とモバイルの sr-only h1 も、未読/既読で絞っているときは「未読の記事」/「既読の記事」にする(カテゴリ・タグ・検索・企業名が優先。マイフィード `?feed=` で絞っているときは「フィード」のまま)。以前はサイドバーで「未読」を選んでも見出しが「フィード」のままで、全件を見ているのか未読だけなのかが見出しから分からなかった
4546
- `(app)` 配下の `<main>` ランドマークは `app/(app)/layout.tsx` の `#main-content` に一本化する(スキップリンクの着地点を兼ねる)。各 page は `<main>` を持たない(入れ子の main は無効なため、記事ページ・その loading も `div`)。以前はフィード・保存・履歴・設定に main が無く、スクリーンリーダーの「メインへ移動」が効かなかった
4647
- サイドバーの折りたたみ見出し(企業 / マイフィード)は `outline-none` のままだとキーボードで Tab を当てても焦点が見えなかったため、`focus-visible:ring-2 focus-visible:ring-accent` を付ける(マウス操作では出ない)
4748
- ルート遷移中(`app/(app)/loading.tsx`)は上部クロームも skeleton で出す(`feed-chrome-skeleton.tsx`: モバイル=検索/絞り込みヘッダー、タブレット=1行ツールバー枠、デスクトップ=上部バー + 見出し行)。モバイルのフィードは共通 Header(ロゴ帯)を隠して `MobileFeedControls` が上部を担うため、loading 側に枠が無いと「遷移中は上部が空 → 解決時に検索行が生えてカードが押し下がる」表示差になる。モバイル skeleton は検索行 + タブ行ぶんの高さ(`h-11` + `pb-2.5` + 下罫)を出す(タブのラベル自体は `?category=` 依存の選択下線を誤って見せないため出さない)

‎features/feed/web/feed-page-title.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ describe('feedPageTitle', () => {
3030
expect(feedPageTitle({ company: 'mercari', sort: 'popular' })).toBe('mercari の記事');
3131
});
3232

33+
it('カテゴリは「表示名 の記事」にする(不正なキーは無視)', () => {
34+
expect(feedPageTitle({ category: 'ai' })).toBe('AI・機械学習 の記事');
35+
expect(feedPageTitle({ category: 'ai', sort: 'popular' })).toBe('AI・機械学習 の記事');
36+
expect(feedPageTitle({ category: 'not-a-category' })).toBeUndefined();
37+
});
38+
39+
it('未読/既読は「未読の記事」/「既読の記事」にする(all・不正な値は無視)', () => {
40+
expect(feedPageTitle({ read: 'unread' })).toBe('未読の記事');
41+
expect(feedPageTitle({ read: 'read', sort: 'popular' })).toBe('既読の記事');
42+
expect(feedPageTitle({ read: 'all' })).toBeUndefined();
43+
expect(feedPageTitle({ read: 'bogus' })).toBeUndefined();
44+
// マイフィードで絞っているときは見出し(「フィード」)と揃えて既定タイトルのまま
45+
expect(feedPageTitle({ read: 'unread', feed: 'f1' })).toBeUndefined();
46+
});
47+
48+
it('検索・タグ・企業はカテゴリ・未読/既読より優先する', () => {
49+
expect(feedPageTitle({ q: 'go', read: 'unread' })).toBe('「go」の検索結果');
50+
expect(feedPageTitle({ tag: 'SRE', category: 'ai' })).toBe('#SRE の記事');
51+
expect(feedPageTitle({ company: 'mercari', read: 'unread' })).toBe('mercari の記事');
52+
expect(feedPageTitle({ category: 'ai', read: 'unread' })).toBe('AI・機械学習 の記事');
53+
});
54+
3355
it('人気順でも検索・タグがあればそちらを優先する', () => {
3456
expect(feedPageTitle({ q: 'go', sort: 'popular' })).toBe('「go」の検索結果');
3557
expect(feedPageTitle({ tag: 'SRE', sort: 'popular' })).toBe('#SRE の記事');

‎features/feed/web/feed-page-title.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { parseReadMode } from '@/features/feed/domain/feed-sort';
2+
import { getCategoryDefinition, isArticleCategory } from '@/shared/categories';
3+
14
const MAX_LABEL = 40;
25

36
function clip(value: string): string {
@@ -8,12 +11,19 @@ function clip(value: string): string {
811
/**
912
* フィード(/)の絞り込み・人気順のときだけ付けるタブ/履歴用のタイトル。無指定なら undefined(既定タイトル)。
1013
* 検索やタグを切り替えても全部同じ「テクブロ|…」だと、複数タブやブラウザ履歴で見分けがつかない。
11-
* 検索が優先(検索語とタグの併用時は検索語を出す)、次にタグ、企業(1 社だけ選んだとき)、最後に人気順(新着順は既定タイトルのまま)。
14+
* 検索が優先(検索語とタグの併用時は検索語を出す)、次にタグ、企業(1 社だけ選んだとき)、カテゴリ、未読/既読、
15+
* 最後に人気順(新着順は既定タイトルのまま)。
1216
*/
1317
export function feedPageTitle(params: {
1418
q?: string;
1519
tag?: string;
1620
company?: string;
21+
/** 生の ?category=。有効なカテゴリキーのときだけ表示名にする。 */
22+
category?: string;
23+
/** 生の ?read=。'unread' / 'read' 以外は無視する(parseReadMode と同じ解釈)。 */
24+
read?: string;
25+
/** 生の ?feed=(マイフィード)。マイフィードで絞っているときは未読/既読をタイトルにしない(見出しと揃える)。 */
26+
feed?: string;
1727
sort?: 'latest' | 'popular';
1828
}): string | undefined {
1929
const q = params.q?.trim();
@@ -22,6 +32,11 @@ export function feedPageTitle(params: {
2232
if (tag) return `#${clip(tag)} の記事`;
2333
const company = params.company?.trim();
2434
if (company) return `${clip(company)} の記事`;
35+
const category = params.category?.trim();
36+
if (isArticleCategory(category)) return `${getCategoryDefinition(category).label} の記事`;
37+
const read = params.feed?.trim() ? 'all' : parseReadMode(params.read?.trim());
38+
if (read === 'unread') return '未読の記事';
39+
if (read === 'read') return '既読の記事';
2540
if (params.sort === 'popular') return '人気の記事';
2641
return undefined;
2742
}

0 commit comments

Comments
 (0)