Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_que
import { DetailTodoModalContainer } from "@/components/todo-modal/detail/DetailTodoModalContainer";
import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider";
import { formatDateKey } from "@/utils/date/date";
import { isTagLabelKey } from "@/utils/todo/tag-label";
import { getDefaultTagLabelKey } from "@/utils/todo/tag-label";

export const HomeTodoContainer = () => {
const tCommon = useTranslations("Common");
Expand Down Expand Up @@ -89,6 +89,15 @@ export const HomeTodoContainer = () => {
{todos.map((todo) => {
const [firstSubtask] = todo.subtasks;

const todoTagLabelKey = todo.tag
? getDefaultTagLabelKey(todo.tag.tagId)
: undefined;
const todoTagName = todo.tag
? todoTagLabelKey
? tCommon(`tag.${todoTagLabelKey}`)
: todo.tag.name
: undefined;

return (
<DetailTodoModalContainer
key={todo.todoId}
Expand All @@ -105,12 +114,7 @@ export const HomeTodoContainer = () => {
isCompleted={todo.completed}
durationSeconds={todo.durationSeconds}
priority={todo.priority}
tagName={
todo.tag &&
(isTagLabelKey(todo.tag.name)
? tCommon(`tag.${todo.tag.name}`)
: todo.tag.name)
}
tagName={todoTagName}
hasMemo={todo.hasMemo}
isRepeated={todo.isRepeated}
timerStatus={todo.timerStatus}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SECONDS_PER_MINUTE } from "@/constants/time";

/**
* API duration 필드(mm:ss)를 초 단위로 변환한다.
* duration 필드(mm:ss, 분 단위가 60을 넘을 수 있음)를 초 단위로 변환한다.
* @param duration - "mm:ss" 형식의 시간 문자열
* @returns 변환된 초
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";

import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/account/profile-type";

Expand All @@ -16,6 +17,7 @@ import { useCreateTag } from "@/queries/tag/use-create-tag";
import { useDeleteTag } from "@/queries/tag/use-delete-tag";
import { useTags } from "@/queries/tag/use-tags";
import { useMyProfile } from "@/queries/use-my-profile";
import { getDefaultTagLabelKey } from "@/utils/todo/tag-label";

const LANGUAGE_REQUEST_MAP: Record<
SettingsLanguage,
Expand All @@ -40,6 +42,7 @@ export interface ConnectCalendarHandlers {

export const useSettingsProfile = () => {
const { locale, commitLanguage } = useSettingsLanguageParam();
const tCommon = useTranslations("Common");

const { data: profile } = useMyProfile();

Expand All @@ -51,11 +54,17 @@ export const useSettingsProfile = () => {
const { mutate: deleteTag } = useDeleteTag();
const { mutate: logoutMutate, isPending: isLoggingOut } = useLogoutAction();

const tagItems = (tagsQuery.data?.tags ?? []).map((tag) => ({
id: tag.tagId,
label: tag.name,
isDefault: tag.isDefault,
}));
const tagItems = (tagsQuery.data?.tags ?? []).map((tag) => {
const labelKey = tag.isDefault
? getDefaultTagLabelKey(tag.tagId)
: undefined;

return {
id: tag.tagId,
label: labelKey ? tCommon(`tag.${labelKey}`) : tag.name,
isDefault: tag.isDefault,
};
});

const handleConnectCalendar = (
isCalendarConnected: boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useSubtaskField } from "@/hooks/todo-modal/use-subtask-field";
import { useTagField } from "@/hooks/todo-modal/use-tag-field";
import { useTimeField } from "@/hooks/todo-modal/use-time-field";
import { useTitleField } from "@/hooks/todo-modal/use-title-field";
import { formatDateKey } from "@/utils/date/date";
import { formatDateKey, formatShortDateLabel } from "@/utils/date/date";

const createDefaultValues = (defaultDate?: Date): CreateTodoRequest => ({
icon: null,
Expand Down Expand Up @@ -56,10 +56,11 @@ export const CreateTodoModalContent = ({
const tCommon = useTranslations("Common");
const tToast = useTranslations("Toast");

const { control, handleSubmit, reset } = useForm<CreateTodoRequest>({
resolver: zodResolver(createTodoRequestSchema),
defaultValues: createDefaultValues(defaultDate),
});
const { control, handleSubmit, reset, formState } =
useForm<CreateTodoRequest>({
resolver: zodResolver(createTodoRequestSchema),
defaultValues: createDefaultValues(defaultDate),
});

const { field: dateField } = useController({ name: "date", control });
const { field: memoField } = useController({ name: "memo", control });
Expand Down Expand Up @@ -146,14 +147,15 @@ export const CreateTodoModalContent = ({
<TodoToolbar
dateLabel={
dateValue
? `${dateValue.getMonth() + 1}.${dateValue.getDate()}`
? formatShortDateLabel(dateValue)
: t("createModal.dateLabel")
}
date={dateValue}
onDateChange={(next) => dateField.onChange(formatDateKey(next))}
timeLabel={timeField.timeDisplay}
timeOptions={timeField.timeOptions}
time={timeField.duration}
isTimeActive={Boolean(formState.dirtyFields.duration)}
onTimeChange={timeField.handleDurationInputChange}
selectedTime={timeField.selectedTime}
onSelectTime={timeField.handleSelectTime}
Expand Down
2 changes: 2 additions & 0 deletions apps/timo-web/hooks/todo-modal/use-detail-subtask-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const useDetailSubtaskField = ({
event: KeyboardEvent<HTMLTextAreaElement>,
) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.nativeEvent.isComposing) return;

event.preventDefault();

setSubtaskInputs((prev) => {
Expand Down
23 changes: 16 additions & 7 deletions apps/timo-web/hooks/todo-modal/use-detail-todo-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ import type {
TodoIconValue,
} from "@repo/timo-design-system/ui";

import { SECONDS_PER_MINUTE } from "@/constants/time";
import { useDetailSubtaskField } from "@/hooks/todo-modal/use-detail-subtask-field";
import { isTagLabelKey } from "@/utils/todo/tag-label";
import { getDefaultTagLabelKey } from "@/utils/todo/tag-label";
import {
TITLE_MAX_WEIGHTED_LENGTH,
truncateToWeightedLength,
} from "@/utils/todo/text-length";
import { convertDurationToTimeText } from "@/utils/todo/todo-time";
import {
convertDurationToTimeText,
convertSecondsToApiDuration,
} from "@/utils/todo/todo-time";

interface DetailTodoFormValues {
date: Date;
Expand All @@ -41,8 +45,8 @@ export const DETAIL_TODO_TIME_OPTIONS: TimeOption[] = [
{ minute: 15, value: "15", unit: "min" },
{ minute: 30, value: "30", unit: "min" },
{ minute: 45, value: "45", unit: "min" },
{ minute: 60, value: "1", unit: "h" },
{ minute: 90, value: "1.5", unit: "h" },
{ minute: 60, value: "60", unit: "min" },
{ minute: 90, value: "90", unit: "min" },
];

export const DETAIL_TODO_WEEKDAY_IDS = [
Expand All @@ -65,8 +69,11 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => {
const durationText = convertDurationToTimeText(todo.durationSeconds);
Comment thread
kimminna marked this conversation as resolved.
const todoTagName = todo.tag?.name ?? "";
const todoIcon = todo.icon ?? null;
const tagLabel = isTagLabelKey(todoTagName)
? tCommon(`tag.${todoTagName}`)
const todoTagLabelKey = todo.tag
? getDefaultTagLabelKey(todo.tag.tagId)
: undefined;
const tagLabel = todoTagLabelKey
? tCommon(`tag.${todoTagLabelKey}`)
: todoTagName;

const { control, handleSubmit, formState } = useForm<DetailTodoFormValues>({
Expand Down Expand Up @@ -140,7 +147,9 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => {

if (!option) return;

timeField.onChange(`${option.value} ${option.unit}`);
timeField.onChange(
convertSecondsToApiDuration(option.minute * SECONDS_PER_MINUTE),
);
};

const changeRepeatFrequency = (frequency: RepeatFrequency) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/timo-web/hooks/todo-modal/use-subtask-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const useSubtaskField = ({ control }: UseSubtaskFieldParams) => {
event: KeyboardEvent<HTMLTextAreaElement>,
) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.nativeEvent.isComposing) return;

event.preventDefault();

setSubtaskInputs((prev) => {
Expand Down
15 changes: 11 additions & 4 deletions apps/timo-web/hooks/todo-modal/use-tag-field.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslations } from "next-intl";
import { overlay } from "overlay-kit";
import { useState } from "react";
import { useController } from "react-hook-form";
Expand All @@ -9,6 +10,7 @@ import { tagCreateDataSchema } from "@/api/common/tag-schema";
import { CreateTagModalContainer } from "@/components/tag/CreateTagModalContainer";
import { useCreateTag } from "@/queries/tag/use-create-tag";
import { useTags } from "@/queries/tag/use-tags";
import { getDefaultTagLabelKey } from "@/utils/todo/tag-label";

const MAX_TAG_COUNT = 8;

Expand All @@ -17,6 +19,7 @@ export interface UseTagFieldParams {
}

export const useTagField = ({ control }: UseTagFieldParams) => {
const tCommon = useTranslations("Common");
const { field } = useController({ name: "tagId", control });
const [isTagLimitToastOpen, setIsTagLimitToastOpen] =
useState<boolean>(false);
Expand All @@ -26,10 +29,14 @@ export const useTagField = ({ control }: UseTagFieldParams) => {
const tagsQuery = useTags();
const { mutate: createTag } = useCreateTag();

const tagOptions = (tagsQuery.data?.tags ?? []).map((tag) => ({
id: tag.tagId,
label: tag.name,
}));
const tagOptions = (tagsQuery.data?.tags ?? []).map((tag) => {
const defaultLabelKey = getDefaultTagLabelKey(tag.tagId);

return {
id: tag.tagId,
label: defaultLabelKey ? tCommon(`tag.${defaultLabelKey}`) : tag.name,
};
});
const tagLabels = tagOptions.map((option) => option.label);
const selectedTagOption = tagOptions.find(
(option) => option.id === field.value,
Expand Down
49 changes: 33 additions & 16 deletions apps/timo-web/hooks/todo-modal/use-time-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ import type { Control } from "react-hook-form";

import { recommendDurationResponseSchema } from "@/api/common/todo-schema";
import { useRecommendDuration } from "@/api/generated/endpoints/ai/ai";
import { SECONDS_PER_HOUR, SECONDS_PER_MINUTE } from "@/constants/time";
import {
convertApiDurationToSeconds,
convertSecondsToApiDuration,
} from "@/utils/todo/todo-time";

const TIME_OPTIONS = [
{ minute: 15, value: "15", unit: "min" },
{ minute: 30, value: "30", unit: "min" },
{ minute: 45, value: "45", unit: "min" },
{ minute: 60, value: "1", unit: "h" },
{ minute: 90, value: "1.5", unit: "h" },
{ minute: 60, value: "60", unit: "min" },
{ minute: 90, value: "90", unit: "min" },
];

const MINUTES_PER_HOUR = 60;

export interface UseTimeFieldParams {
control: Control<CreateTodoRequest>;
}

/**
* 숫자와 콜론만 허용한다. 콜론은 첫 번째 것만 유지하고(그 뒤 콜론은 제거),
* 시(hour) 자릿수는 제한하지 않는다. 분(minute)만 2자리로 자른다.
* 분(minute) 자릿수는 제한하지 않는다. 초(second)만 2자리로 자른다.
*/
const formatDurationInput = (raw: string): string => {
const sanitized = raw.replace(/[^\d:]/g, "");
Expand All @@ -43,10 +46,18 @@ const formatDurationInput = (raw: string): string => {
return `${hours}:${minutes}`;
};

const formatMinutesToDuration = (totalMinutes: number): string => {
const hours = Math.floor(totalMinutes / MINUTES_PER_HOUR);
const minutes = totalMinutes % MINUTES_PER_HOUR;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
/**
* "mm:ss" duration 문자열을 트리거에 보여줄 "h:mm" 시계 표기로 변환한다.
* 분(minute)·초(second) 두 자리를 모두 반영해 전체 길이를 기준으로 계산한다.
* @example formatDurationAsClockLabel("90:00") // "1:30"
*/
const formatDurationAsClockLabel = (duration: string): string => {
const totalSeconds = convertApiDurationToSeconds(duration);
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor(
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
);
return `${hours}:${minutes.toString().padStart(2, "0")}`;
};

export const useTimeField = ({ control }: UseTimeFieldParams) => {
Expand All @@ -64,7 +75,7 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => {
const applyRecommendedDuration = (duration: string) => {
setRecommendedDuration(duration);
setSelectedTime("ai");
setTimeDisplay(duration);
setTimeDisplay(formatDurationAsClockLabel(duration));
field.onChange(duration);
};

Expand All @@ -86,7 +97,9 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => {
}

applyRecommendedDuration(
formatMinutesToDuration(parsed.data.recommendedMinutes),
convertSecondsToApiDuration(
parsed.data.recommendedMinutes * SECONDS_PER_MINUTE,
),
);
},
onError: () => {
Expand All @@ -100,7 +113,7 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => {
if (value === "ai") {
setSelectedTime(value);
if (recommendedDuration) {
setTimeDisplay(recommendedDuration);
setTimeDisplay(formatDurationAsClockLabel(recommendedDuration));
field.onChange(recommendedDuration);
}
return;
Expand All @@ -111,14 +124,18 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => {
const option = TIME_OPTIONS.find((item) => item.minute === value);
if (!option) return;

setTimeDisplay(`${option.value} ${option.unit}`);
field.onChange(`${option.minute}:00`);
const formatted = convertSecondsToApiDuration(
option.minute * SECONDS_PER_MINUTE,
);
setTimeDisplay(formatDurationAsClockLabel(formatted));
field.onChange(formatted);
};

const handleDurationInputChange = (value: string) => {
setSelectedTime(undefined);
const formatted = formatDurationInput(value);
setTimeDisplay(formatted);
setRecommendedDuration(formatted);
setTimeDisplay(formatDurationAsClockLabel(formatted));
field.onChange(formatted);
};

Expand All @@ -129,7 +146,7 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => {
};

return {
duration: field.value,
duration: recommendedDuration ?? field.value,
timeOptions: TIME_OPTIONS,
selectedTime,
timeDisplay,
Expand Down
16 changes: 13 additions & 3 deletions apps/timo-web/utils/todo/tag-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ export const TAG_LABEL_KEYS = [
"work",
"exercise",
"assignment",
"additional",
] as const;

export type TagLabelKey = (typeof TAG_LABEL_KEYS)[number];

export const isTagLabelKey = (value: string): value is TagLabelKey =>
(TAG_LABEL_KEYS as readonly string[]).includes(value);
/**
* 기본 태그 4종은 서버 tagId가 고정값으로 내려오므로,
* 이름 문자열이 아닌 tagId로 매핑해 프론트에서 next-intl로 렌더링한다.
*/
const DEFAULT_TAG_ID_TO_LABEL_KEY: Record<number, TagLabelKey> = {
1: "dailyLife",
2: "work",
3: "exercise",
4: "assignment",
};

export const getDefaultTagLabelKey = (tagId: number): TagLabelKey | undefined =>
DEFAULT_TAG_ID_TO_LABEL_KEY[tagId];
Loading
Loading