From cb835bca3475ba70620552be05652deec8e35c0f Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:04:06 +0900 Subject: [PATCH 01/10] fix: unify timestamp format to ISO 8601 (Z-suffix) for correct timezone display timeFormatUTC() was stripping the Z suffix from ISO 8601 strings, causing dayjs() to interpret them as local time. This made .tz('Asia/Tokyo') a no-op, displaying UTC values as if they were JST. - Remove timeFormatUTC and pass ISO 8601 strings through to DB as-is - Fix timeFormatTz to use dayjs.utc() for correct parsing - Update all display code to use dayjs.utc() instead of dayjs() - Add CLAUDE.md section on datetime/timezone conventions - Add tests for timeFormatTz Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 8 +++++ app/libs/business-hours.ts | 4 +-- app/routes/$orgSlug/+components/pr-block.tsx | 4 +-- .../analysis/feedbacks/_index/index.tsx | 6 +++- .../$orgSlug/analysis/reviews/index.tsx | 6 +++- app/routes/$orgSlug/workload/$login/index.tsx | 18 ++++++----- .../+components/team-stacks-chart.tsx | 2 +- batch/db/mutations.ts | 31 ++----------------- batch/github/review-response.ts | 5 +-- batch/helper/timeformat.test.ts | 24 ++++++++++++++ batch/helper/timeformat.ts | 18 +++-------- 11 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 batch/helper/timeformat.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a47fd83c..30421171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,14 @@ Types are generated to `app/services/type.ts` from the database. **CamelCasePlugin と `sql` テンプレート**: `sql` テンプレートリテラル内の識別子は CamelCasePlugin で変換されない。`sql` 内でカラムを参照するときは `sql.ref('tableName.columnName')` を使うこと。 +### 日時・タイムゾーンの原則 + +- **DB 保存形式**: ISO 8601(`2026-03-16T02:56:35Z`)。Z付きで保存し、ローカルタイム形式(`2026-03-16 02:56:35`)は使わない +- **DB から読んだ日時のパース**: 必ず `dayjs.utc(value)` を使う。`dayjs(value)` はローカルタイムとして解釈されるため、タイムゾーン変換が正しく動かない +- **表示層でのタイムゾーン変換**: `dayjs.utc(value).tz(timezone)` のパターンを使う +- **batch での書き込み**: GitHub API から取得した ISO 8601 文字列をそのまま DB に保存する。独自のフォーマット変換をかけない +- **`timeFormatTz`**(`batch/helper/timeformat.ts`): レポートやスプレッドシート出力用。内部で `dayjs.utc()` を使用済み + ### Path Aliases Use `~/` prefix for imports from `app/` directory: diff --git a/app/libs/business-hours.ts b/app/libs/business-hours.ts index c8e70d39..20446c05 100644 --- a/app/libs/business-hours.ts +++ b/app/libs/business-hours.ts @@ -72,8 +72,8 @@ export const calculateBusinessHours = ( end: string, timezone = 'Asia/Tokyo', ): number => { - const startDate = dayjs(start).tz(timezone) - const endDate = dayjs(end).tz(timezone) + const startDate = dayjs.utc(start).tz(timezone) + const endDate = dayjs.utc(end).tz(timezone) if (!startDate.isBefore(endDate)) return 0 diff --git a/app/routes/$orgSlug/+components/pr-block.tsx b/app/routes/$orgSlug/+components/pr-block.tsx index b5d34658..231eb83e 100644 --- a/app/routes/$orgSlug/+components/pr-block.tsx +++ b/app/routes/$orgSlug/+components/pr-block.tsx @@ -77,7 +77,7 @@ function getSizeColor(complexity: string | null): BlockColor { } function getAgeColor(createdAt: string): BlockColor { - const days = dayjs().diff(dayjs(createdAt), 'day', true) + const days = dayjs().diff(dayjs.utc(createdAt), 'day', true) for (const t of AGE_THRESHOLDS) { if (days < t.maxDays) return { bg: t.bg, ring: t.ring, bgFaint: t.bgFaint } } @@ -126,7 +126,7 @@ export function PRPopoverContent({ showAuthor?: boolean reviewState?: string }) { - const ageDays = Math.floor(dayjs().diff(dayjs(pr.createdAt), 'day', true)) + const ageDays = Math.floor(dayjs().diff(dayjs.utc(pr.createdAt), 'day', true)) const stateInfo = reviewState ? REVIEW_STATE_STYLE[reviewState] : null return (
diff --git a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx index 2cd513e1..e638174b 100644 --- a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx +++ b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx @@ -74,7 +74,11 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = periodMonths === 'all' ? '2000-01-01T00:00:00.000Z' - : dayjs().subtract(periodMonths, 'month').startOf('day').toISOString() + : dayjs() + .subtract(periodMonths, 'month') + .utc() + .startOf('day') + .toISOString() const page = Number(url.searchParams.get('page') || '1') const perPage = Number(url.searchParams.get('per_page') || '20') diff --git a/app/routes/$orgSlug/analysis/reviews/index.tsx b/app/routes/$orgSlug/analysis/reviews/index.tsx index a3455263..03737d4a 100644 --- a/app/routes/$orgSlug/analysis/reviews/index.tsx +++ b/app/routes/$orgSlug/analysis/reviews/index.tsx @@ -65,7 +65,11 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = periodMonths === 'all' ? '2000-01-01T00:00:00.000Z' - : dayjs().subtract(periodMonths, 'month').startOf('day').toISOString() + : dayjs() + .subtract(periodMonths, 'month') + .utc() + .startOf('day') + .toISOString() const teams = await listTeams(organization.id) diff --git a/app/routes/$orgSlug/workload/$login/index.tsx b/app/routes/$orgSlug/workload/$login/index.tsx index 01fd7f8f..f185c380 100644 --- a/app/routes/$orgSlug/workload/$login/index.tsx +++ b/app/routes/$orgSlug/workload/$login/index.tsx @@ -168,16 +168,19 @@ export default function MemberWeeklyPage({ holiday: holidays[dateStr] ?? null, created: createdPRs.filter( (pr) => - dayjs(pr.pullRequestCreatedAt).tz(timezone).format('YYYY-MM-DD') === - dateStr, + dayjs + .utc(pr.pullRequestCreatedAt) + .tz(timezone) + .format('YYYY-MM-DD') === dateStr, ), merged: mergedPRs.filter( (pr) => - dayjs(pr.mergedAt).tz(timezone).format('YYYY-MM-DD') === dateStr, + dayjs.utc(pr.mergedAt).tz(timezone).format('YYYY-MM-DD') === dateStr, ), reviewed: reviews.filter( (r) => - dayjs(r.submittedAt).tz(timezone).format('YYYY-MM-DD') === dateStr, + dayjs.utc(r.submittedAt).tz(timezone).format('YYYY-MM-DD') === + dateStr, ), } }) @@ -421,7 +424,8 @@ export default function MemberWeeklyPage({ title={pr.title} url={pr.url} complexity={pr.complexity} - date={dayjs(pr.pullRequestCreatedAt) + date={dayjs + .utc(pr.pullRequestCreatedAt) .tz(timezone) .format('M/D HH:mm')} /> @@ -443,7 +447,7 @@ export default function MemberWeeklyPage({ title={pr.title} url={pr.url} complexity={pr.complexity} - date={dayjs(pr.mergedAt).tz(timezone).format('M/D HH:mm')} + date={dayjs.utc(pr.mergedAt).tz(timezone).format('M/D HH:mm')} extra={pr.totalTime ? `${pr.totalTime.toFixed(1)}d` : undefined} /> ))} @@ -465,7 +469,7 @@ export default function MemberWeeklyPage({ url={r.url} complexity={r.complexity} author={r.author} - date={dayjs(r.submittedAt).tz(timezone).format('M/D HH:mm')} + date={dayjs.utc(r.submittedAt).tz(timezone).format('M/D HH:mm')} reviewState={r.state} /> ))} diff --git a/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx b/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx index 256e29f9..235aea17 100644 --- a/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx +++ b/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx @@ -37,7 +37,7 @@ function sortBySize(prs: StackPR[]): StackPR[] { // --- Age mode --- function getAgeDays(pr: StackPR): number { - return dayjs().diff(dayjs(pr.createdAt), 'day', true) + return dayjs().diff(dayjs.utc(pr.createdAt), 'day', true) } function sortByAge(prs: StackPR[]): StackPR[] { diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index c18fe918..abd96b7d 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -2,33 +2,15 @@ import type { Insertable } from 'kysely' import { getTenantDb, type TenantDB } from '~/app/services/tenant-db.server' import type { OrganizationId } from '~/app/types/organization' import { logger } from '../helper/logger' -import { timeFormatUTC } from '../helper/timeformat' export function upsertPullRequest( organizationId: OrganizationId, data: Insertable, ) { - const firstCommittedAt = timeFormatUTC(data.firstCommittedAt) - const pullRequestCreatedAt = timeFormatUTC(data.pullRequestCreatedAt) - const firstReviewedAt = timeFormatUTC(data.firstReviewedAt) - const mergedAt = timeFormatUTC(data.mergedAt) - const closedAt = timeFormatUTC(data.closedAt) - const releasedAt = timeFormatUTC(data.releasedAt) - const updatedAt = timeFormatUTC(data.updatedAt) - const tenantDb = getTenantDb(organizationId) return tenantDb .insertInto('pullRequests') - .values({ - ...data, - firstCommittedAt, - pullRequestCreatedAt, - firstReviewedAt, - mergedAt, - closedAt, - releasedAt, - updatedAt, - }) + .values(data) .onConflict((oc) => oc.columns(['repositoryId', 'number']).doUpdateSet((eb) => ({ repo: eb.ref('excluded.repo'), @@ -87,16 +69,7 @@ export async function batchUpsertPullRequests( for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize) - const values = chunk.map((data) => ({ - ...data, - firstCommittedAt: timeFormatUTC(data.firstCommittedAt), - pullRequestCreatedAt: timeFormatUTC(data.pullRequestCreatedAt), - firstReviewedAt: timeFormatUTC(data.firstReviewedAt), - mergedAt: timeFormatUTC(data.mergedAt), - closedAt: timeFormatUTC(data.closedAt), - releasedAt: timeFormatUTC(data.releasedAt), - updatedAt: timeFormatUTC(data.updatedAt), - })) + const values = chunk await tenantDb .insertInto('pullRequests') diff --git a/batch/github/review-response.ts b/batch/github/review-response.ts index 59a18866..3dce50b3 100644 --- a/batch/github/review-response.ts +++ b/batch/github/review-response.ts @@ -10,7 +10,7 @@ export const analyzeReviewResponse = ( // 古い順に並べて、レビュアーが変わったらその時間差を反応時間として記録 for (const res of sortBy( - comments.filter((d) => dayjs(d.createdAt) > dayjs().add(-90, 'days')), + comments.filter((d) => dayjs.utc(d.createdAt) > dayjs().add(-90, 'days')), [(x) => x.createdAt, 'asc'], )) { if (lastRes && lastRes.user !== res.user) { @@ -18,7 +18,8 @@ export const analyzeReviewResponse = ( author: res.user, createdAt: res.createdAt, responseTime: - (dayjs(res.createdAt).unix() - dayjs(lastRes.createdAt).unix()) / + (dayjs.utc(res.createdAt).unix() - + dayjs.utc(lastRes.createdAt).unix()) / 60 / 60, }) diff --git a/batch/helper/timeformat.test.ts b/batch/helper/timeformat.test.ts new file mode 100644 index 00000000..1ad3928f --- /dev/null +++ b/batch/helper/timeformat.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { timeFormatTz } from './timeformat' + +describe('timeFormatTz', () => { + it('converts ISO 8601 UTC to Asia/Tokyo', () => { + expect(timeFormatTz('2026-03-16T02:56:35Z', 'Asia/Tokyo')).toBe( + '2026-03-16 11:56:35', + ) + }) + + it('converts ISO 8601 UTC to US/Pacific', () => { + expect(timeFormatTz('2026-03-16T02:56:35Z', 'US/Pacific')).toBe( + '2026-03-15 19:56:35', + ) + }) + + it('returns null for null input', () => { + expect(timeFormatTz(null, 'Asia/Tokyo')).toBeNull() + }) + + it('returns null for undefined input', () => { + expect(timeFormatTz(undefined, 'Asia/Tokyo')).toBeNull() + }) +}) diff --git a/batch/helper/timeformat.ts b/batch/helper/timeformat.ts index fccbac34..972c5511 100644 --- a/batch/helper/timeformat.ts +++ b/batch/helper/timeformat.ts @@ -1,16 +1,5 @@ import dayjs from '~/app/libs/dayjs' -export function timeFormatUTC( - date: T, -): T extends string ? string : null { - if (date === null || date === undefined) { - return null as T extends string ? string : null - } - return dayjs(date) - .utc(false) - .format('YYYY-MM-DD HH:mm:ss') as T extends string ? string : null -} - export function timeFormatTz( date: T, tz: string, @@ -18,7 +7,8 @@ export function timeFormatTz( if (date === null || date === undefined) { return null as T extends string ? string : null } - return dayjs(date).tz(tz).format('YYYY-MM-DD HH:mm:ss') as T extends string - ? string - : null + return dayjs + .utc(date) + .tz(tz) + .format('YYYY-MM-DD HH:mm:ss') as T extends string ? string : null } From 196349198dd14a09ad366c68602050d9e2b33420 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:08:24 +0900 Subject: [PATCH 02/10] chore: merge AGENTS.md into CLAUDE.md and gitignore it AGENTS.md is auto-generated by opensrc, so it should not be tracked. Its content (source code reference docs) is now in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index db5c4cc7..7b769d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ lab/output/ # opensrc - source code for packages opensrc/ +AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index 30421171..f0404d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,7 +226,7 @@ Org-scoped tables (have `organizationId` column): `companyGithubUsers`, `exportS ### Source Code Reference -Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details. See `opensrc/sources.json` for the list of available packages. +Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details. See `opensrc/sources.json` for the list of available packages and their versions. Use this source code when you need to understand how a package works internally, not just its types/interface. ```bash npx opensrc # npm package (e.g., npx opensrc zod) From c16d4196fd8b97f565662a5013b2a294a47fb5be Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:12:36 +0900 Subject: [PATCH 03/10] fix: use dayjs.utc() on both sides of timestamp comparison Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/github/review-response.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/batch/github/review-response.ts b/batch/github/review-response.ts index 3dce50b3..5ff3a338 100644 --- a/batch/github/review-response.ts +++ b/batch/github/review-response.ts @@ -10,7 +10,9 @@ export const analyzeReviewResponse = ( // 古い順に並べて、レビュアーが変わったらその時間差を反応時間として記録 for (const res of sortBy( - comments.filter((d) => dayjs.utc(d.createdAt) > dayjs().add(-90, 'days')), + comments.filter( + (d) => dayjs.utc(d.createdAt) > dayjs.utc().add(-90, 'days'), + ), [(x) => x.createdAt, 'asc'], )) { if (lastRes && lastRes.user !== res.user) { From b5b000ca1ad2203d984b70df363027c15c2aee1b Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:16:37 +0900 Subject: [PATCH 04/10] refactor: inline chunk variable and add analyzeReviewResponse tests Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/db/mutations.ts | 3 +- batch/github/review-response.test.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 batch/github/review-response.test.ts diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index abd96b7d..ee280fb7 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -69,11 +69,10 @@ export async function batchUpsertPullRequests( for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize) - const values = chunk await tenantDb .insertInto('pullRequests') - .values(values) + .values(chunk) .onConflict((oc) => oc.columns(['repositoryId', 'number']).doUpdateSet((eb) => ({ repo: eb.ref('excluded.repo'), diff --git a/batch/github/review-response.test.ts b/batch/github/review-response.test.ts new file mode 100644 index 00000000..ed40c1b5 --- /dev/null +++ b/batch/github/review-response.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { ShapedGitHubReviewComment } from './model' +import { analyzeReviewResponse } from './review-response' + +const comment = ( + user: string, + createdAt: string, +): ShapedGitHubReviewComment => ({ + id: 1, + user, + isBot: false, + url: '', + createdAt, +}) + +describe('analyzeReviewResponse', () => { + it('returns empty for no comments', () => { + expect(analyzeReviewResponse([])).toEqual([]) + }) + + it('returns empty when all comments are from the same user', () => { + const result = analyzeReviewResponse([ + comment('alice', '2026-03-01T10:00:00Z'), + comment('alice', '2026-03-01T11:00:00Z'), + ]) + expect(result).toEqual([]) + }) + + it('calculates response time when reviewer changes', () => { + const result = analyzeReviewResponse([ + comment('alice', '2026-03-01T10:00:00Z'), + comment('bob', '2026-03-01T12:00:00Z'), + ]) + expect(result).toHaveLength(1) + expect(result[0].author).toBe('bob') + expect(result[0].responseTime).toBe(2) // 2 hours + }) + + it('tracks multiple reviewer changes', () => { + const result = analyzeReviewResponse([ + comment('alice', '2026-03-01T10:00:00Z'), + comment('bob', '2026-03-01T11:00:00Z'), + comment('alice', '2026-03-01T13:00:00Z'), + ]) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ author: 'bob', responseTime: 1 }) + expect(result[1]).toMatchObject({ author: 'alice', responseTime: 2 }) + }) + + it('filters out comments older than 90 days', () => { + const result = analyzeReviewResponse([ + comment('alice', '2025-01-01T10:00:00Z'), + comment('bob', '2025-01-01T12:00:00Z'), + ]) + expect(result).toEqual([]) + }) +}) From b9af7a14f78facf5641fed27eee20f30c1aa7d83 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:19:34 +0900 Subject: [PATCH 05/10] fix: compute sinceDate in UTC from the start to avoid timezone dependency dayjs().subtract().utc() subtracts in local time then converts, making the day boundary environment-dependent. dayjs.utc().subtract() is correct. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx | 6 +----- app/routes/$orgSlug/analysis/reviews/index.tsx | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx index e638174b..049553f2 100644 --- a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx +++ b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx @@ -74,11 +74,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = periodMonths === 'all' ? '2000-01-01T00:00:00.000Z' - : dayjs() - .subtract(periodMonths, 'month') - .utc() - .startOf('day') - .toISOString() + : dayjs.utc().subtract(periodMonths, 'month').startOf('day').toISOString() const page = Number(url.searchParams.get('page') || '1') const perPage = Number(url.searchParams.get('per_page') || '20') diff --git a/app/routes/$orgSlug/analysis/reviews/index.tsx b/app/routes/$orgSlug/analysis/reviews/index.tsx index 03737d4a..2b6ffd77 100644 --- a/app/routes/$orgSlug/analysis/reviews/index.tsx +++ b/app/routes/$orgSlug/analysis/reviews/index.tsx @@ -65,11 +65,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = periodMonths === 'all' ? '2000-01-01T00:00:00.000Z' - : dayjs() - .subtract(periodMonths, 'month') - .utc() - .startOf('day') - .toISOString() + : dayjs.utc().subtract(periodMonths, 'month').startOf('day').toISOString() const teams = await listTeams(organization.id) From eaba36d50d7a9a7f2a9c85fb7ffb614723d8f820 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 11:25:25 +0900 Subject: [PATCH 06/10] fix: convert remaining dayjs() calls to dayjs.utc() for DB/API timestamps - batch/bizlogic/cycletime.ts: all diff calculations - throughput queries (merged, deployed, ongoing): hour diff calculations - size-badge-popover, feedback-columns: .fromNow() display - data-management: refreshRequestedAt display - repository-item: pushedAt display - Update all cycletime test fixtures to ISO 8601 format Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/size-badge-popover.tsx | 2 +- .../_index/+components/feedback-columns.tsx | 2 +- .../settings/data-management/index.tsx | 3 ++- .../+components/repository-item.tsx | 2 +- .../deployed/+functions/queries.server.ts | 17 +++++++------ .../merged/+functions/queries.server.ts | 12 ++++++---- .../ongoing/+functions/queries.server.ts | 2 +- batch/bizlogic/cycletime.ts | 16 +++++++------ batch/bizlogic/cycletime_codingTime.test.ts | 24 +++++++++---------- batch/bizlogic/cycletime_deployTime.test.ts | 12 +++++----- batch/bizlogic/cycletime_pickupTime.test.ts | 16 ++++++------- batch/bizlogic/cycletime_reviewTime.test.ts | 12 +++++----- batch/bizlogic/cycletime_totalTime.test.ts | 22 ++++++++--------- 13 files changed, 75 insertions(+), 67 deletions(-) diff --git a/app/components/size-badge-popover.tsx b/app/components/size-badge-popover.tsx index 0c909422..51aa7943 100644 --- a/app/components/size-badge-popover.tsx +++ b/app/components/size-badge-popover.tsx @@ -242,7 +242,7 @@ export function SizeBadgePopover({ )} - {feedbackName} · {dayjs(feedbackAt).fromNow()} + {feedbackName} · {dayjs.utc(feedbackAt).fromNow()} )}