refactor(stage6): 去掉热力图写死的默认区间与线上文件的演示数据

热力图默认区间
- 新增 src/shared/date-range.ts(近 N 天滚动窗口,按本地自然日)。
- 前端两个热力图与后端两个 query 模型原先都把默认区间写死为
  2026-01-01 ~ 2026-07-13,会随时间永久指向一段历史数据;
  现改为"近 30 天"滚动,并把 now 作为可注入参数以便测试。
- 两个 model 测试改为注入固定时钟并断言滚动窗口,不再硬编码日期。

氢能看板去演示数据(board/EnergyBiBoardApp.tsx,6554 -> 3765 行)
- 取消线上路径的 mock 兜底:省份下拉、站点汇总表、客户费用汇总表、
  区域排行在 overview 未就绪时显示空,而不是假的 25 个站点 / Top30 账单。
- 删除已确认"零渲染点"的死代码(用 TypeScript AST 精确定位声明范围,
  避免手工推断括号边界):
    KpiDrillModal / CustomerBillDrillModal / StationBillDrillModal /
    DrillPeriodControls / getYearTimeRange / getDrillTimeRange /
    MOCK_STATION_SUMMARY_LIST / MOCK_CUSTOMER_SUMMARY_LIST
  这些组件内含大量写死的 2026-08-08 快照日期与假流水,均不可达。
  线上 KPI 下钻走的是 PrototypeDrillModal(真实 API)。
- 单站日报"最后更新时间"初值不再用伪造时间戳,改为与接口空态一致的文案。

注:本次未改动任何 DOM 结构与 CSS 类名;删除的均为不可达分支。
lint / test(134) / build 全绿。
This commit is contained in:
dsh-agent
2026-09-11 10:23:31 +08:00
parent b28ca49491
commit 5d14a28b0f
10 changed files with 102 additions and 2841 deletions
File diff suppressed because it is too large Load Diff
@@ -129,7 +129,8 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
const setStartDate = onStartDateChange ?? setLocalStartDate; const setStartDate = onStartDateChange ?? setLocalStartDate;
const setEndDate = onEndDateChange ?? setLocalEndDate; const setEndDate = onEndDateChange ?? setLocalEndDate;
const [drillStationId, setDrillStationId] = useState<string | null>(null); const [drillStationId, setDrillStationId] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState('2026-08-12 10:00'); // 初值不用伪造时间戳;成功回调会写入真实 latestLedgerTime,缺数据时与之一致。
const [updatedAt, setUpdatedAt] = useState('暂无账本更新时间');
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null); const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null);
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null); const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
@@ -4,10 +4,11 @@ import HydrogenAmapCanvas from './HydrogenAmapCanvas';
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel'; import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters'; import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api'; import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import { recentDayRange } from '../../shared/date-range';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types'; import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
const DEFAULT_START = '2026-01-01'; // 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
const DEFAULT_END = '2026-07-13'; const FALLBACK_RANGE = recentDayRange(30);
const integerFormat = new Intl.NumberFormat('zh-CN'); const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }); const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
@@ -37,8 +38,8 @@ export default function HydrogenHeatmapModule() {
const [config, setConfig] = useState<AmapConfig | null>(null); const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null); const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null); const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START); const [startDate, setStartDate] = useState(FALLBACK_RANGE.start);
const [endDate, setEndDate] = useState(DEFAULT_END); const [endDate, setEndDate] = useState(FALLBACK_RANGE.end);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all'); const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg'); const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
@@ -112,8 +113,8 @@ export default function HydrogenHeatmapModule() {
}, [deferredQuery, endDate, metric, payer, startDate]); }, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => { const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START); setStartDate(meta?.startDate || FALLBACK_RANGE.start);
setEndDate(meta?.endDate || DEFAULT_END); setEndDate(meta?.endDate || FALLBACK_RANGE.end);
setQuery(''); setQuery('');
setPayer('all'); setPayer('all');
setMetric('kg'); setMetric('kg');
@@ -164,7 +165,7 @@ export default function HydrogenHeatmapModule() {
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" /> <Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p> <p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || FALLBACK_RANGE.end}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}> <button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />} {isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button> </button>
@@ -180,8 +181,8 @@ export default function HydrogenHeatmapModule() {
<HydrogenHeatmapFilters <HydrogenHeatmapFilters
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
minDate={meta?.startDate || DEFAULT_START} minDate={meta?.startDate || FALLBACK_RANGE.start}
maxDate={meta?.endDate || DEFAULT_END} maxDate={meta?.endDate || FALLBACK_RANGE.end}
query={query} query={query}
payer={payer} payer={payer}
metric={metric} metric={metric}
@@ -4,10 +4,11 @@ import AmapHeatmapCanvas from './AmapHeatmapCanvas';
import HeatmapDetailPanel from './HeatmapDetailPanel'; import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters'; import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api'; import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import { recentDayRange } from '../../shared/date-range';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
const DEFAULT_START = '2026-01-01'; // 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
const DEFAULT_END = '2026-07-13'; const FALLBACK_RANGE = recentDayRange(30);
const numberFormat = new Intl.NumberFormat('zh-CN'); const numberFormat = new Intl.NumberFormat('zh-CN');
function Metric({ value, label }: { value: number; label: string }) { function Metric({ value, label }: { value: number; label: string }) {
@@ -24,8 +25,8 @@ export default function VehicleHeatmapModule() {
const [config, setConfig] = useState<AmapConfig | null>(null); const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HeatmapResponse | null>(null); const [data, setData] = useState<HeatmapResponse | null>(null);
const [nearby, setNearby] = useState<NearbyResponse | null>(null); const [nearby, setNearby] = useState<NearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START); const [startDate, setStartDate] = useState(FALLBACK_RANGE.start);
const [endDate, setEndDate] = useState(DEFAULT_END); const [endDate, setEndDate] = useState(FALLBACK_RANGE.end);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [batchModel, setBatchModel] = useState(''); const [batchModel, setBatchModel] = useState('');
const [metric, setMetric] = useState<HeatmapMetric>('locations'); const [metric, setMetric] = useState<HeatmapMetric>('locations');
@@ -103,8 +104,8 @@ export default function VehicleHeatmapModule() {
}, [batchModel, deferredQuery, endDate, startDate]); }, [batchModel, deferredQuery, endDate, startDate]);
const reset = () => { const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START); setStartDate(meta?.startDate || FALLBACK_RANGE.start);
setEndDate(meta?.endDate || DEFAULT_END); setEndDate(meta?.endDate || FALLBACK_RANGE.end);
setQuery(''); setQuery('');
setBatchModel(''); setBatchModel('');
setMetric('locations'); setMetric('locations');
@@ -147,7 +148,7 @@ export default function VehicleHeatmapModule() {
<Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" /> <Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p> <p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || FALLBACK_RANGE.end}</p>
<button <button
type="button" type="button"
onClick={toggleFullscreen} onClick={toggleFullscreen}
@@ -179,8 +180,8 @@ export default function VehicleHeatmapModule() {
<HeatmapFilters <HeatmapFilters
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
minDate={meta?.startDate || DEFAULT_START} minDate={meta?.startDate || FALLBACK_RANGE.start}
maxDate={meta?.endDate || DEFAULT_END} maxDate={meta?.endDate || FALLBACK_RANGE.end}
query={query} query={query}
batchModel={batchModel} batchModel={batchModel}
batchModels={meta?.batchModels || []} batchModels={meta?.batchModels || []}
@@ -11,6 +11,11 @@ import {
serializeHydrogenStation, serializeHydrogenStation,
type HydrogenStationRecord, type HydrogenStationRecord,
} from './hydrogen-heatmap-model.js'; } from './hydrogen-heatmap-model.js';
import { recentDayRange } from '../../shared/date-range.js';
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
const NOW = new Date('2026-07-13T12:00:00');
const FALLBACK = recentDayRange(30, NOW);
function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord { function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord {
return { return {
@@ -28,10 +33,10 @@ function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStatio
}; };
} }
test('加氢热力图查询继续使用固定默认日期、去除搜索空白并限制枚举值', () => { test('加氢热力图查询默认使用近 30 天滚动窗口、去除搜索空白并限制枚举值', () => {
assert.deepEqual(parseHydrogenHeatmapQuery({}), { assert.deepEqual(parseHydrogenHeatmapQuery({}, NOW), {
startDate: '2026-01-01', startDate: FALLBACK.start,
endDate: '2026-07-13', endDate: FALLBACK.end,
query: '', query: '',
payer: 'all', payer: 'all',
metric: 'kg', metric: 'kg',
@@ -42,9 +47,9 @@ test('加氢热力图查询继续使用固定默认日期、去除搜索空白
query: ' 嘉兴 ', query: ' 嘉兴 ',
payer: 'customer', payer: 'customer',
metric: 'vehicles', metric: 'vehicles',
}), { }, NOW), {
startDate: '2026-06-01', startDate: '2026-06-01',
endDate: '2026-07-13', endDate: FALLBACK.end,
query: '嘉兴', query: '嘉兴',
payer: 'customer', payer: 'customer',
metric: 'vehicles', metric: 'vehicles',
+7 -5
View File
@@ -1,3 +1,4 @@
import { recentDayRange } from '../../shared/date-range.js';
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles'; export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer'; export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
@@ -28,8 +29,8 @@ type NearbyQueryInput = {
radiusKm?: string; radiusKm?: string;
}; };
export const HYDROGEN_HEATMAP_DEFAULT_START = '2026-01-01'; /** 未显式传日期时的兜底窗口:近 N 天滚动,不再写死历史区间。 */
export const HYDROGEN_HEATMAP_DEFAULT_END = '2026-07-13'; export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
function isDate(value: string | undefined): value is string { function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
@@ -45,10 +46,11 @@ function parsePayer(value: string | undefined): HydrogenPayer {
return 'all'; return 'all';
} }
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput) { export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput, now: Date = new Date()) {
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
return { return {
startDate: isDate(input.startDate) ? input.startDate : HYDROGEN_HEATMAP_DEFAULT_START, startDate: isDate(input.startDate) ? input.startDate : fallback.start,
endDate: isDate(input.endDate) ? input.endDate : HYDROGEN_HEATMAP_DEFAULT_END, endDate: isDate(input.endDate) ? input.endDate : fallback.end,
query: (input.query || '').trim(), query: (input.query || '').trim(),
payer: parsePayer(input.payer), payer: parsePayer(input.payer),
metric: parseMetric(input.metric), metric: parseMetric(input.metric),
+5 -4
View File
@@ -3,11 +3,11 @@ import type { RowDataPacket } from 'mysql2';
import pool from '../db/mysql.js'; import pool from '../db/mysql.js';
import type { AuthUser } from '../auth/types.js'; import type { AuthUser } from '../auth/types.js';
import { canAccessEnergy } from '../auth/types.js'; import { canAccessEnergy } from '../auth/types.js';
import { recentDayRange } from '../../shared/date-range.js';
import { import {
buildHydrogenPoints, buildHydrogenPoints,
filterHydrogenStationsByRadius, filterHydrogenStationsByRadius,
HYDROGEN_HEATMAP_DEFAULT_END, HEATMAP_DEFAULT_WINDOW_DAYS,
HYDROGEN_HEATMAP_DEFAULT_START,
parseHydrogenHeatmapQuery, parseHydrogenHeatmapQuery,
parseNearbyQuery, parseNearbyQuery,
rankHydrogenStations, rankHydrogenStations,
@@ -127,9 +127,10 @@ router.get('/meta', async (c) => {
const profile = profileRows[0][0] || {}; const profile = profileRows[0][0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0; const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0; const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS);
return c.json({ return c.json({
startDate: profile.start_date || HYDROGEN_HEATMAP_DEFAULT_START, startDate: profile.start_date || fallback.start,
endDate: profile.end_date || HYDROGEN_HEATMAP_DEFAULT_END, endDate: profile.end_date || fallback.end,
totalRefuelCount, totalRefuelCount,
eligibleRefuelCount, eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount), excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
@@ -12,6 +12,11 @@ import {
vehicleGridPrecision, vehicleGridPrecision,
type VehicleHeatmapRecord, type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js'; } from './vehicle-heatmap-model.js';
import { recentDayRange } from '../../shared/date-range.js';
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
const NOW = new Date('2026-07-13T12:00:00');
const FALLBACK = recentDayRange(30, NOW);
function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord { function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord {
return { return {
@@ -27,10 +32,10 @@ function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRe
}; };
} }
test('车辆热力图查询继续使用固定默认日期和原有字符串处理规则', () => { test('车辆热力图查询默认使用近 30 天滚动窗口,并保留原有字符串处理规则', () => {
assert.deepEqual(parseVehicleHeatmapQuery({}), { assert.deepEqual(parseVehicleHeatmapQuery({}, NOW), {
startDate: '2026-01-01', startDate: FALLBACK.start,
endDate: '2026-07-13', endDate: FALLBACK.end,
query: '', query: '',
batchModel: '', batchModel: '',
metric: 'locations', metric: 'locations',
@@ -41,9 +46,9 @@ test('车辆热力图查询继续使用固定默认日期和原有字符串处
query: ' 粤A ', query: ' 粤A ',
batchModel: ' 190台冷链 ', batchModel: ' 190台冷链 ',
metric: 'vehicles', metric: 'vehicles',
}), { }, NOW), {
startDate: '2026-06-01', startDate: '2026-06-01',
endDate: '2026-07-13', endDate: FALLBACK.end,
query: ' 粤A ', query: ' 粤A ',
batchModel: '190台冷链', batchModel: '190台冷链',
metric: 'vehicles', metric: 'vehicles',
+11 -5
View File
@@ -1,3 +1,4 @@
import { recentDayRange } from '../../shared/date-range.js';
export type VehicleHeatmapMetric = 'locations' | 'vehicles'; export type VehicleHeatmapMetric = 'locations' | 'vehicles';
export type VehicleHeatmapRecord = { export type VehicleHeatmapRecord = {
@@ -33,17 +34,22 @@ type NearbyQueryInput = {
radiusKm?: string; radiusKm?: string;
}; };
export const VEHICLE_HEATMAP_DEFAULT_START = '2026-01-01'; /**
export const VEHICLE_HEATMAP_DEFAULT_END = '2026-07-13'; * 未显式传日期时的兜底窗口。
* 此前写死为 2026-01-01 ~ 2026-07-13,会让默认查询永久指向一段历史数据;
* 现改为"近 N 天"滚动窗口。
*/
export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
function isDate(value: string | undefined): value is string { function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
} }
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput) { export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput, now: Date = new Date()) {
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
return { return {
startDate: isDate(input.startDate) ? input.startDate : VEHICLE_HEATMAP_DEFAULT_START, startDate: isDate(input.startDate) ? input.startDate : fallback.start,
endDate: isDate(input.endDate) ? input.endDate : VEHICLE_HEATMAP_DEFAULT_END, endDate: isDate(input.endDate) ? input.endDate : fallback.end,
query: input.query || '', query: input.query || '',
batchModel: (input.batchModel || '').trim(), batchModel: (input.batchModel || '').trim(),
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const, metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
+29
View File
@@ -0,0 +1,29 @@
/**
* 自然日区间工具(按本地日历日,不使用 UTC)。
* 前后端都要按"上海/本地自然日"取数,散落各处的实现容易在跨日与月末出错,
* 因此收敛到 shared 这一层。
*/
export interface DayRange {
start: string;
end: string;
}
function formatYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/**
* 近 N 天(含今天)的区间。
*
* 用于接口尚未返回数据水位(meta)时的兜底范围。此前代码把兜底写死成
* `2026-01-01 ~ 2026-07-13`,会随着时间推移永久指向一段历史数据。
*/
export function recentDayRange(days = 30, now: Date = new Date()): DayRange {
const span = Math.max(1, Math.floor(days));
const end = new Date(now);
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - (span - 1));
return { start: formatYmd(start), end: formatYmd(end) };
}