Skip to content

Commit 8f0126d

Browse files
nenrinyearikotome
authored andcommitted
unify timezone handling across task-decomp flow
1 parent 4765069 commit 8f0126d

7 files changed

Lines changed: 80 additions & 35 deletions

File tree

backend/src/features/task-decompose/task-calendar.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
TaskDecomposeRequest,
44
TaskDecomposeResult,
55
} from "./task-decompose.types";
6+
import { normalizeTaskTimezone } from "./task-timezone";
67
import type {
78
CalendarCreatedEvent,
89
CalendarSyncResult,
@@ -296,7 +297,7 @@ export async function createCalendarEvents(
296297
input: CreateCalendarEventsInput,
297298
): Promise<CalendarSyncResult> {
298299
const accessToken = await getGoogleAccessToken(env, input.userId);
299-
const timezone = input.request.timezone ?? "UTC";
300+
const timezone = normalizeTaskTimezone(input.request.timezone);
300301
const calendarId = PRIMARY_CALENDAR_ID;
301302
const overallTaskName = toOverallTaskName(
302303
input.request.task,

backend/src/features/task-decompose/task-decompose.service.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import type {
33
TaskDecomposeResult,
44
TaskSubtask,
55
} from "./task-decompose.types";
6+
import { normalizeTaskTimezone } from "./task-timezone";
67

78
const AI_MODEL = "@cf/meta/llama-3.1-8b-instruct-fp8";
89
const DEFAULT_MAX_STEPS = 6;
910
const MIN_DURATION_MINUTES = 15;
1011
const MAX_DURATION_MINUTES = 240;
1112
const DEFAULT_INFERRED_DEADLINE_DAYS = 7;
12-
const DEFAULT_PLANNING_TIMEZONE = "Asia/Tokyo";
1313

1414
function asStringArray(value: unknown): string[] {
1515
if (!Array.isArray(value)) {
@@ -76,14 +76,6 @@ function fallbackSummary(task: string): string {
7676
return `"${task}" を実行可能な単位へ分解した計画です。`;
7777
}
7878

79-
function normalizeTimezone(timezone: string | undefined): string {
80-
if (!timezone || timezone.trim().length === 0) {
81-
return DEFAULT_PLANNING_TIMEZONE;
82-
}
83-
84-
return timezone.trim();
85-
}
86-
8779
function formatPromptDateInTimezone(date: Date, timezone: string): string {
8880
try {
8981
return new Intl.DateTimeFormat("en-CA", {
@@ -260,7 +252,7 @@ function normalizeResult(
260252

261253
function createPrompt(payload: TaskDecomposeRequest): string {
262254
const maxSteps = payload.maxSteps ?? DEFAULT_MAX_STEPS;
263-
const planningTimezone = normalizeTimezone(payload.timezone);
255+
const planningTimezone = normalizeTaskTimezone(payload.timezone);
264256
const now = new Date();
265257

266258
const deadlineGuidance = payload.deadline

backend/src/features/task-decompose/task-decompose.validation.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TaskDecomposeRequest } from "./task-decompose.types";
2+
import { normalizeTaskTimezone } from "./task-timezone";
23

34
const MAX_STEPS_LIMIT = 12;
45

@@ -20,17 +21,12 @@ function parseDeadline(value: unknown): string | undefined {
2021
return parsed.toISOString();
2122
}
2223

23-
function parseTimezone(value: unknown): string | undefined {
24+
function parseTimezone(value: unknown): string {
2425
if (typeof value !== "string") {
25-
return undefined;
26-
}
27-
28-
const trimmed = value.trim();
29-
if (trimmed.length === 0) {
30-
return undefined;
26+
return normalizeTaskTimezone(undefined);
3127
}
3228

33-
return trimmed;
29+
return normalizeTaskTimezone(value);
3430
}
3531

3632
function parseMaxSteps(value: unknown): number | undefined {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const DEFAULT_TASK_TIMEZONE = "Asia/Tokyo";
2+
3+
export function normalizeTaskTimezone(
4+
value: string | null | undefined,
5+
): string {
6+
if (!value || value.trim().length === 0) {
7+
return DEFAULT_TASK_TIMEZONE;
8+
}
9+
10+
const candidate = value.trim();
11+
try {
12+
return new Intl.DateTimeFormat("en-US", {
13+
timeZone: candidate,
14+
}).resolvedOptions().timeZone;
15+
} catch {
16+
return DEFAULT_TASK_TIMEZONE;
17+
}
18+
}

frontend/src/app/task-decomp/components/task-decomp-steps.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type ResultStepProps = {
5858
phase: RunPhase;
5959
statusLabel: string;
6060
record: WorkflowRecord | null;
61+
userTimezone: string;
6162
displayErrorMessage: string | null;
6263
onStartNewTask: () => void;
6364
};
@@ -335,11 +336,13 @@ export function ResultStep({
335336
phase,
336337
statusLabel,
337338
record,
339+
userTimezone,
338340
displayErrorMessage,
339341
onStartNewTask,
340342
}: ResultStepProps) {
341343
const breakdown = record?.llmOutput;
342344
const calendarResult = record?.calendarOutput;
345+
const displayTimezone = record?.timezone ?? userTimezone;
343346

344347
return (
345348
<Stack gap={5}>
@@ -381,8 +384,7 @@ export function ResultStep({
381384
</Text>
382385
<HStack gap={2} flexWrap="wrap">
383386
<Badge colorPalette="blue" variant="subtle">
384-
期限:{" "}
385-
{formatDateTime(subtask.dueAt, record?.timezone)}
387+
期限: {formatDateTime(subtask.dueAt, displayTimezone)}
386388
</Badge>
387389
<Badge colorPalette="teal" variant="subtle">
388390
{subtask.durationMinutes}
@@ -418,12 +420,12 @@ export function ResultStep({
418420
<Text fontSize="sm" color="fg.muted">
419421
{formatDateTime(
420422
eventItem.startAt,
421-
calendarResult.timezone,
423+
calendarResult.timezone ?? displayTimezone,
422424
)}{" "}
423425
-{" "}
424426
{formatDateTime(
425427
eventItem.endAt,
426-
calendarResult.timezone,
428+
calendarResult.timezone ?? displayTimezone,
427429
)}
428430
</Text>
429431
</Stack>

frontend/src/app/task-decomp/helpers.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,48 @@ export function formatDateTime(
8989
return value;
9090
}
9191

92-
return parsed.toLocaleString("ja-JP", {
93-
timeZone: timezone ?? DEFAULT_USER_TIMEZONE,
94-
year: "numeric",
95-
month: "2-digit",
96-
day: "2-digit",
97-
hour: "2-digit",
98-
minute: "2-digit",
99-
});
92+
const safeTimezone = normalizeTimezone(timezone);
93+
try {
94+
return parsed.toLocaleString("ja-JP", {
95+
timeZone: safeTimezone,
96+
year: "numeric",
97+
month: "2-digit",
98+
day: "2-digit",
99+
hour: "2-digit",
100+
minute: "2-digit",
101+
});
102+
} catch {
103+
return parsed.toLocaleString("ja-JP", {
104+
year: "numeric",
105+
month: "2-digit",
106+
day: "2-digit",
107+
hour: "2-digit",
108+
minute: "2-digit",
109+
});
110+
}
111+
}
112+
113+
export function normalizeTimezone(timezone: string | null | undefined): string {
114+
if (!timezone || timezone.trim().length === 0) {
115+
return DEFAULT_USER_TIMEZONE;
116+
}
117+
118+
const candidate = timezone.trim();
119+
try {
120+
return new Intl.DateTimeFormat("en-US", {
121+
timeZone: candidate,
122+
}).resolvedOptions().timeZone;
123+
} catch {
124+
return DEFAULT_USER_TIMEZONE;
125+
}
126+
}
127+
128+
export function detectBrowserTimezone(): string {
129+
if (typeof window === "undefined") {
130+
return DEFAULT_USER_TIMEZONE;
131+
}
132+
133+
return normalizeTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
100134
}
101135

102136
export function toStatusLabel(

frontend/src/app/task-decomp/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ import {
3737
ResultStep,
3838
RunningStep,
3939
} from "./components/task-decomp-steps";
40-
import { DEFAULT_USER_TIMEZONE, STEP_ITEMS } from "./constants";
40+
import { STEP_ITEMS } from "./constants";
4141
import {
42+
detectBrowserTimezone,
4243
formatDateTime,
4344
needsCalendarReauth,
4445
toDeadlineIso,
@@ -105,6 +106,7 @@ export default function TaskDecompPage() {
105106
const loadedDraftUserIdRef = useRef<string | null>(null);
106107

107108
const signedInUser = useMemo(() => session?.user ?? null, [session]);
109+
const userTimezone = useMemo(() => detectBrowserTimezone(), []);
108110
const statusLabel = useMemo(
109111
() => toStatusLabel(phase, workflowStatus, record),
110112
[phase, workflowStatus, record],
@@ -371,8 +373,7 @@ export default function TaskDecompPage() {
371373
const safeMaxSteps = Number.isFinite(parsedMaxSteps)
372374
? Math.min(Math.max(Math.trunc(parsedMaxSteps), 1), 12)
373375
: 6;
374-
const timezone =
375-
Intl.DateTimeFormat().resolvedOptions().timeZone ?? DEFAULT_USER_TIMEZONE;
376+
const timezone = userTimezone;
376377

377378
setPhase("starting");
378379
setErrorMessage(null);
@@ -539,6 +540,7 @@ export default function TaskDecompPage() {
539540
phase={phase}
540541
statusLabel={statusLabel}
541542
record={record}
543+
userTimezone={userTimezone}
542544
displayErrorMessage={displayErrorMessage}
543545
onStartNewTask={handleStartNewTask}
544546
/>
@@ -695,7 +697,7 @@ export default function TaskDecompPage() {
695697
<Text fontSize="xs" color="fg.muted">
696698
{formatDateTime(
697699
item.createdAt,
698-
item.timezone,
700+
item.timezone ?? userTimezone,
699701
)}{" "}
700702
/ {toStatusLabelFromRecord(item.status)}
701703
</Text>

0 commit comments

Comments
 (0)