Compare commits
2 Commits
b80994d114
...
cb309cf0fa
| Author | SHA1 | Date | |
|---|---|---|---|
| cb309cf0fa | |||
| e9839d85e2 |
@@ -218,6 +218,7 @@
|
|||||||
- READ NEXT는 내일 첫 작업과 오늘 미처리 할 일 개수만 보여주도록 줄였고, 오늘 코멘트 반복 노출은 제거했다.
|
- READ NEXT는 내일 첫 작업과 오늘 미처리 할 일 개수만 보여주도록 줄였고, 오늘 코멘트 반복 노출은 제거했다.
|
||||||
- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다.
|
- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다.
|
||||||
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
|
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
|
||||||
|
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
|
||||||
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||||
|
|||||||
@@ -976,6 +976,8 @@ const weeklyRecords = computed(() => {
|
|||||||
const date = toDateValue(key)
|
const date = toDateValue(key)
|
||||||
const weekdayShort = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]
|
const weekdayShort = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]
|
||||||
const focusedMinutes = getFocusedMinutes(record)
|
const focusedMinutes = getFocusedMinutes(record)
|
||||||
|
const activeTasks = record.tasks.filter((task) => task.title.trim())
|
||||||
|
const doneTasks = activeTasks.filter((task) => task.checked)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -983,6 +985,8 @@ const weeklyRecords = computed(() => {
|
|||||||
dateLabel: `${`${date.getMonth() + 1}`.padStart(2, '0')}.${`${date.getDate()}`.padStart(2, '0')}`,
|
dateLabel: `${`${date.getMonth() + 1}`.padStart(2, '0')}.${`${date.getDate()}`.padStart(2, '0')}`,
|
||||||
focusedMinutes,
|
focusedMinutes,
|
||||||
focusedTime: formatMinutesKorean(focusedMinutes),
|
focusedTime: formatMinutesKorean(focusedMinutes),
|
||||||
|
completedTasks: doneTasks.length,
|
||||||
|
totalTasks: activeTasks.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
|
<div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
|
||||||
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-[25px] lg:w-[394px]">
|
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-[21px] lg:w-[394px]">
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 text-muted">
|
<div class="flex items-center gap-2 text-muted">
|
||||||
<span class="shrink-0">TASKS</span>
|
<span class="shrink-0">TASKS</span>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import GuideTooltip from './GuideTooltip.vue'
|
import GuideTooltip from './GuideTooltip.vue'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
overviewCards: {
|
overviewCards: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -33,6 +34,133 @@ defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range'])
|
const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range'])
|
||||||
|
|
||||||
|
const flowScrollerRef = ref(null)
|
||||||
|
const flowDragState = ref(null)
|
||||||
|
const hoveredFlowRecord = ref(null)
|
||||||
|
const flowTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
const flowGapClass = computed(() => {
|
||||||
|
if (props.weeklyRecords.length > 14) {
|
||||||
|
return 'gap-1'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'gap-3'
|
||||||
|
})
|
||||||
|
|
||||||
|
const flowItemStyle = computed(() => {
|
||||||
|
const count = props.weeklyRecords.length
|
||||||
|
|
||||||
|
if (count <= 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = count > 14 ? 4 : 12
|
||||||
|
const minWidth = count <= 7 ? 76 : count <= 14 ? 46 : 18
|
||||||
|
|
||||||
|
if (count <= 31) {
|
||||||
|
return {
|
||||||
|
flex: '0 0 auto',
|
||||||
|
width: `max(${minWidth}px, calc((100% - ${(count - 1) * gap}px) / ${count}))`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
flex: '0 0 auto',
|
||||||
|
width: '18px',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const flowBarWidth = computed(() => {
|
||||||
|
if (props.weeklyRecords.length <= 7) {
|
||||||
|
return '48px'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.weeklyRecords.length <= 14) {
|
||||||
|
return '30px'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '12px'
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldShowFlowTime = computed(() => props.weeklyRecords.length <= 14)
|
||||||
|
|
||||||
|
function shouldShowFlowLabel(index) {
|
||||||
|
const count = props.weeklyRecords.length
|
||||||
|
|
||||||
|
if (count <= 14) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Math.ceil(count / 8)
|
||||||
|
return index === 0 || index === count - 1 || index % interval === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFlowTooltipPosition(event) {
|
||||||
|
const tooltipWidth = 176
|
||||||
|
const tooltipHeight = 86
|
||||||
|
const margin = 12
|
||||||
|
const viewportWidth = window.innerWidth || tooltipWidth
|
||||||
|
const safeX = Math.min(
|
||||||
|
Math.max(event.clientX, margin + tooltipWidth / 2),
|
||||||
|
viewportWidth - margin - tooltipWidth / 2,
|
||||||
|
)
|
||||||
|
const safeY = Math.max(event.clientY - 18, margin + tooltipHeight)
|
||||||
|
|
||||||
|
flowTooltipPosition.value = {
|
||||||
|
x: safeX,
|
||||||
|
y: safeY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFlowTooltip(record, event) {
|
||||||
|
hoveredFlowRecord.value = record
|
||||||
|
updateFlowTooltipPosition(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFlowTooltip(event) {
|
||||||
|
if (!hoveredFlowRecord.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFlowTooltipPosition(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideFlowTooltip() {
|
||||||
|
hoveredFlowRecord.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFlowDrag(event) {
|
||||||
|
if (!flowScrollerRef.value || event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowDragState.value = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
scrollLeft: flowScrollerRef.value.scrollLeft,
|
||||||
|
}
|
||||||
|
flowScrollerRef.value.setPointerCapture?.(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFlowDrag(event) {
|
||||||
|
if (!flowDragState.value || !flowScrollerRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
flowScrollerRef.value.scrollLeft =
|
||||||
|
flowDragState.value.scrollLeft - (event.clientX - flowDragState.value.startX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFlowDrag(event) {
|
||||||
|
if (!flowDragState.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowScrollerRef.value?.releasePointerCapture?.(event.pointerId)
|
||||||
|
flowDragState.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,7 +224,7 @@ const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
<div class="grid items-start gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
||||||
<div class="flex items-end justify-between gap-4">
|
<div class="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -113,22 +241,42 @@ const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex min-h-[230px] items-stretch gap-3 overflow-x-auto border-b border-stone-200 pb-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="record in weeklyRecords"
|
ref="flowScrollerRef"
|
||||||
|
class="mt-8 flex h-[268px] select-none items-stretch overflow-x-auto overscroll-x-contain border-b border-stone-200 pb-4 cursor-grab active:cursor-grabbing"
|
||||||
|
:class="flowGapClass"
|
||||||
|
@pointerdown="startFlowDrag"
|
||||||
|
@pointermove="moveFlowDrag"
|
||||||
|
@pointerup="stopFlowDrag"
|
||||||
|
@pointercancel="stopFlowDrag"
|
||||||
|
@pointerleave="stopFlowDrag"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(record, index) in weeklyRecords"
|
||||||
:key="record.key"
|
:key="record.key"
|
||||||
class="flex min-w-[64px] flex-col items-center justify-end gap-3"
|
class="relative flex flex-col items-center justify-end gap-3"
|
||||||
|
:style="flowItemStyle"
|
||||||
|
@mouseenter="showFlowTooltip(record, $event)"
|
||||||
|
@mousemove="moveFlowTooltip"
|
||||||
|
@mouseleave="hideFlowTooltip"
|
||||||
>
|
>
|
||||||
<div class="flex h-44 items-end">
|
<div class="flex h-44 items-end">
|
||||||
<div
|
<div
|
||||||
class="w-11 rounded-t-full bg-stone-900/90 transition-all"
|
class="rounded-t-full bg-stone-900/90 transition-all"
|
||||||
:style="{ height: `${record.barHeight}%` }"
|
:style="{ width: flowBarWidth, height: `${record.barHeight}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="h-14 text-center">
|
||||||
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.dateLabel }}</p>
|
<template v-if="shouldShowFlowLabel(index)">
|
||||||
<p class="mt-1 text-[10px] font-bold tracking-[0.12em] text-stone-400">{{ record.weekday }}</p>
|
<p class="select-none text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.dateLabel }}</p>
|
||||||
<p class="mt-1 text-[11px] font-semibold text-stone-800">{{ record.focusedTime }}</p>
|
<p class="mt-1 select-none text-[10px] font-bold tracking-[0.12em] text-stone-400">{{ record.weekday }}</p>
|
||||||
|
</template>
|
||||||
|
<p
|
||||||
|
v-if="shouldShowFlowTime"
|
||||||
|
class="mt-1 select-none text-[11px] font-semibold text-stone-800"
|
||||||
|
>
|
||||||
|
{{ record.focusedTime }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -138,6 +286,21 @@ const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range
|
|||||||
선택 기간에 기록이 없습니다.
|
선택 기간에 기록이 없습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hoveredFlowRecord"
|
||||||
|
class="pointer-events-none fixed z-[80] w-44 -translate-x-1/2 -translate-y-full rounded-2xl border border-stone-200 bg-white px-3 py-2 text-left shadow-[0_16px_40px_rgba(28,25,23,0.14)]"
|
||||||
|
:style="{ left: `${flowTooltipPosition.x}px`, top: `${flowTooltipPosition.y}px` }"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">
|
||||||
|
{{ hoveredFlowRecord.dateLabel }} {{ hoveredFlowRecord.weekday }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-[11px] font-semibold text-stone-900">
|
||||||
|
FOCUSED {{ hoveredFlowRecord.focusedTime }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-[10px] font-semibold tracking-[0.04em] text-stone-500">
|
||||||
|
TASKS {{ hoveredFlowRecord.completedTasks }} / {{ hoveredFlowRecord.totalTasks }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
@@ -162,6 +325,8 @@ const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range
|
|||||||
아직 통계를 보여줄 기록이 충분하지 않습니다.
|
아직 통계를 보여줄 기록이 충분하지 않습니다.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -172,28 +337,32 @@ const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range
|
|||||||
:dismissible="false"
|
:dismissible="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<div
|
<div
|
||||||
v-for="record in recentRecords"
|
v-for="record in recentRecords"
|
||||||
:key="record.key"
|
:key="record.key"
|
||||||
class="rounded-2xl border border-stone-200 bg-stone-50/80 p-4"
|
class="rounded-2xl border border-stone-200 bg-stone-50/80 p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex h-full flex-col justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[12px] font-bold tracking-[0.08em] text-stone-900">{{ record.dateLabel }}</p>
|
<p class="text-[12px] font-bold tracking-[0.08em] text-stone-900">{{ record.dateLabel }}</p>
|
||||||
<p class="mt-2 text-[11px] font-semibold leading-5 text-stone-600">
|
<p class="mt-2 text-[11px] font-semibold leading-5 text-stone-600">
|
||||||
{{ record.comment || '코멘트 없음' }}
|
{{ record.comment || '코멘트 없음' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div>
|
||||||
<p class="text-sm font-semibold tracking-[-0.03em] text-stone-900">{{ record.focusedTime }}</p>
|
<p class="text-sm font-semibold tracking-[-0.03em] text-stone-900">{{ record.focusedTime }}</p>
|
||||||
<p class="mt-1 text-[11px] font-semibold text-stone-500">{{ record.completionRate }}%</p>
|
<p class="mt-1 text-[11px] font-semibold text-stone-500">완료율 {{ record.completionRate }}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="recentRecords.length === 0"
|
||||||
|
class="rounded-2xl border border-dashed border-stone-300 px-4 py-8 text-sm font-semibold text-stone-500"
|
||||||
|
>
|
||||||
|
선택 기간에 최근 기록이 없습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user