Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## [Unreleased]

### Added
- VisitorStats: 통계 조회 기간을 날짜로 직접 지정할 수 있습니다. 최대 366일을 조회하며, 이를 넘기면 종료일 기준으로 시작일을 조정하고 화면에 안내합니다
- Shop: 기본 주문서에 주문인 이메일 선택 입력을 추가했습니다. 입력한 주소는 형식 검증 후 암호화해 저장하고 관리자 주문 상세에 표시하며, 주문인 대상 이메일 알림이 설정된 경우 수신 주소로 사용합니다
- Shop(관리자): 카테고리 트리에서 자식이 있는 항목을 접고 펼칠 수 있으며, 전체 펼치기·전체 접기를 지원합니다. 기본은 전체 펼침이며 접힌 자식도 트리 저장에 포함됩니다
- Shop(관리자): 상품 목록에서 최신순·낮은가격순·높은가격순·조회순을 선택할 수 있습니다. 상품 등록·수정 화면에서 목록으로 돌아올 때도 선택한 정렬을 유지합니다
Expand Down
36 changes: 36 additions & 0 deletions plugins/VisitorStats/Service/VisitorStatsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,15 +332,26 @@ public function getCampaignTrend(int $domainId, string $period, ?string $campaig
return $result;
}

/** 직접 지정 기간의 최대 일수 — 일별 추이를 한 화면에 그릴 수 있는 범위 */
private const CUSTOM_PERIOD_MAX_DAYS = 366;

/**
* 기간 문자열 → [startDate, endDate] 변환
*
* 프리셋(today, last_7_days …) 외에 'custom:YYYY-MM-DD:YYYY-MM-DD' 로 직접 지정한다.
* 형식이 틀리거나 시작일이 종료일보다 늦으면 최근 7일로 본다.
*
* @return array{0: string, 1: string}
*/
public function periodToDates(string $period): array
{
$today = date('Y-m-d');

if (str_starts_with($period, 'custom:')) {
return $this->customPeriodToDates($period)
?? [date('Y-m-d', strtotime('-6 days')), $today];
}

return match ($period) {
'today' => [$today, $today],
'yesterday' => [date('Y-m-d', strtotime('-1 day')), date('Y-m-d', strtotime('-1 day'))],
Expand All @@ -351,6 +362,31 @@ public function periodToDates(string $period): array
};
}

/**
* 'custom:YYYY-MM-DD:YYYY-MM-DD' 해석 — 너무 긴 기간은 종료일 기준으로 자른다.
*
* @return array{0: string, 1: string}|null 형식이 틀리면 null
*/
private function customPeriodToDates(string $period): ?array
{
if (!preg_match('/^custom:(\d{4})-(\d{2})-(\d{2}):(\d{4})-(\d{2})-(\d{2})$/', $period, $m)) {
return null;
}
if (!checkdate((int) $m[2], (int) $m[3], (int) $m[1]) || !checkdate((int) $m[5], (int) $m[6], (int) $m[4])) {
return null;
}

$startDate = "{$m[1]}-{$m[2]}-{$m[3]}";
$endDate = "{$m[4]}-{$m[5]}-{$m[6]}";
if ($startDate > $endDate) {
return null;
}

$earliest = date('Y-m-d', strtotime($endDate . ' -' . (self::CUSTOM_PERIOD_MAX_DAYS - 1) . ' days'));

return [max($startDate, $earliest), $endDate];
}

/**
* 전기 대비 변화율 (%)
*/
Expand Down
92 changes: 92 additions & 0 deletions plugins/VisitorStats/assets/js/period-picker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* VisitorStats — 기간 직접 지정
*
* 통계 화면의 기간 select(#vs-period / #cs-period / #cv-period)에 '기간 지정' 옵션과
* 날짜 입력을 붙인다. 화면 스크립트는 select.value 를 그대로 API 로 보내므로, [적용]을
* 누르면 이 옵션의 값을 'custom:시작일:종료일' 로 바꾸고 change 를 발생시킨다.
* 서버 해석은 VisitorStatsService::periodToDates() 가 맡는다.
*/
(function () {
'use strict';

function ymd(date) {
return date.getFullYear()
+ '-' + String(date.getMonth() + 1).padStart(2, '0')
+ '-' + String(date.getDate()).padStart(2, '0');
}

function warn(message) {
if (window.MubloRequest && MubloRequest.showAlert) {
MubloRequest.showAlert(message, 'warning');
} else {
alert(message);
}
}

function enhance(select) {
var today = ymd(new Date());
var weekAgoDate = new Date();
weekAgoDate.setDate(weekAgoDate.getDate() - 6);
var weekAgo = ymd(weekAgoDate);

var option = document.createElement('option');
option.textContent = '기간 지정';
option.value = 'custom:' + weekAgo + ':' + today;
select.appendChild(option);

// select 를 감싸 날짜 입력을 옆에 둔다 — 부모 flex 배치(양끝 정렬 등)를 흐트리지 않도록
var group = document.createElement('div');
group.className = 'd-flex align-items-center gap-1';
select.parentNode.insertBefore(group, select);
group.appendChild(select);

var picker = document.createElement('div');
picker.className = 'align-items-center gap-1 d-none';
picker.innerHTML =
'<input type="date" class="form-control form-control-sm" style="width:auto;" aria-label="시작일">' +
'<span class="text-muted">~</span>' +
'<input type="date" class="form-control form-control-sm" style="width:auto;" aria-label="종료일">' +
'<button type="button" class="btn btn-sm btn-default text-nowrap">적용</button>';
group.appendChild(picker);

var inputs = picker.querySelectorAll('input');
var startInput = inputs[0];
var endInput = inputs[1];
startInput.value = weekAgo;
endInput.value = today;
startInput.max = today;
endInput.max = today;

select.addEventListener('change', function () {
var isCustom = select.value === option.value;
picker.classList.toggle('d-none', !isCustom);
picker.classList.toggle('d-flex', isCustom);
});

picker.querySelector('button').addEventListener('click', function () {
if (!startInput.value || !endInput.value) {
warn('시작일과 종료일을 모두 선택해주세요.');
return;
}
if (startInput.value > endInput.value) {
warn('시작일이 종료일보다 늦습니다.');
return;
}

// 서버와 같은 양 끝 날짜 포함 366일 제한. UTC 날짜 연산으로 DST 영향을 피한다.
var earliestDate = new Date(endInput.value + 'T00:00:00Z');
earliestDate.setUTCDate(earliestDate.getUTCDate() - 365);
var earliest = earliestDate.toISOString().slice(0, 10);
if (startInput.value < earliest) {
startInput.value = earliest;
warn('조회 기간은 최대 366일입니다. 시작일을 ' + earliest + '로 조정했습니다.');
}

option.value = 'custom:' + startInput.value + ':' + endInput.value;
select.value = option.value;
select.dispatchEvent(new Event('change'));
});
}

document.querySelectorAll('#vs-period, #cs-period, #cv-period').forEach(enhance);
})();
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,40 @@ public function testPaginationSizeIsBounded(): void
self::assertSame(100, $result['pagination']['perPage']);
self::assertSame(1, $result['pagination']['totalPages']);
}

public function testCustomPeriodUsesGivenDates(): void
{
self::assertSame(
['2026-09-01', '2026-09-15'],
$this->service->periodToDates('custom:2026-09-01:2026-09-15')
);
}

public function testSingleDayCustomPeriodIsAllowed(): void
{
self::assertSame(
['2026-09-01', '2026-09-01'],
$this->service->periodToDates('custom:2026-09-01:2026-09-01')
);
}

/**
* 날짜가 아니거나 거꾸로 된 기간은 최근 7일로 본다 — 프리셋의 모르는 값과 같은 처리.
*/
public function testInvalidCustomPeriodFallsBackToLastSevenDays(): void
{
$lastSevenDays = $this->service->periodToDates('last_7_days');

foreach (['custom:2026-09-15:2026-09-01', 'custom:2026-02-30:2026-03-01', 'custom:abc', 'custom:2026-9-1:2026-9-5'] as $period) {
self::assertSame($lastSevenDays, $this->service->periodToDates($period), $period);
}
}

public function testCustomPeriodIsCappedAtOneYearBeforeEndDate(): void
{
self::assertSame(
['2025-09-15', '2026-09-15'],
$this->service->periodToDates('custom:2020-01-01:2026-09-15')
);
}
}
3 changes: 3 additions & 0 deletions plugins/VisitorStats/views/Admin/_nav.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@

<?php // 캠페인키 인터랙티브 가이드 (탭 강조 + 설정 페이지 투어) — serve max-age 24h 대응 filemtime 버전 ?>
<script src="/serve/plugin/VisitorStats/assets/js/campaign-tour.js?<?= @filemtime(MUBLO_PLUGIN_PATH . '/VisitorStats/assets/js/campaign-tour.js') ?: 1 ?>" defer></script>

<?php // 기간 select 에 '기간 지정'(날짜 직접 선택)을 붙인다 — defer 라 탭 화면의 select 가 그려진 뒤 실행된다 ?>
<script src="/serve/plugin/VisitorStats/assets/js/period-picker.js?<?= @filemtime(MUBLO_PLUGIN_PATH . '/VisitorStats/assets/js/period-picker.js') ?: 1 ?>" defer></script>
48 changes: 48 additions & 0 deletions tests/Browser/visitor-stats-period-picker.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const path = require('path');
const { test, expect } = require('@playwright/test');

// 실제 기간 선택 스크립트를 실행한다. 서버·로그인·DB는 사용하지 않는다.
for (const timezoneId of ['Asia/Seoul', 'America/New_York']) {
test.describe(timezoneId, () => {
test.use({ timezoneId });

for (const id of ['vs-period', 'cs-period', 'cv-period']) {
test(`${id}: 표시 기간과 요청 기간이 366일 제한에 맞춰 함께 보정된다`, async ({ page }) => {
await page.setContent(`<style>.d-none{display:none}.d-flex{display:flex}</style>
<select id="${id}"><option value="last_7_days">최근 7일</option></select>`);
await page.evaluate(() => {
window.notices = [];
window.periods = [];
window.MubloRequest = { showAlert: message => window.notices.push(message) };
document.querySelector('select').addEventListener('change', event => {
window.periods.push(event.target.value);
});
});
await page.addScriptTag({
path: path.resolve(__dirname, '../../plugins/VisitorStats/assets/js/period-picker.js'),
});
await page.selectOption('select', { label: '기간 지정' });

// 일반 범위, 하루, 정확히 366일, 367일, 윤년을 가로지르는 범위.
for (const [start, end, expectedStart, adjusted] of [
['2026-09-01', '2026-09-15', '2026-09-01', false],
['2026-09-15', '2026-09-15', '2026-09-15', false],
['2025-09-15', '2026-09-15', '2025-09-15', false],
['2025-09-14', '2026-09-15', '2025-09-15', true],
['2020-01-01', '2024-03-01', '2023-03-02', true],
]) {
await page.evaluate(() => { window.notices = []; window.periods = []; });
await page.getByLabel('시작일').fill(start);
await page.getByLabel('종료일').fill(end);
await page.getByRole('button', { name: '적용' }).click();
await expect(page.getByLabel('시작일')).toHaveValue(expectedStart);
await expect(page.getByLabel('종료일')).toHaveValue(end);
expect(await page.evaluate(() => window.periods)).toEqual([`custom:${expectedStart}:${end}`]);
const notices = await page.evaluate(() => window.notices);
expect(notices).toHaveLength(adjusted ? 1 : 0);
if (adjusted) expect(notices[0]).toContain(expectedStart);
}
});
}
});
}
Loading