import { addShanghaiDays, parseShanghaiYmd, shanghaiMonthRange, shanghaiWeekRange, shanghaiYearMonth, shanghaiYmd, startOfShanghaiDay, } from './shanghai-date'; export const DASHBOARD_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const; export type DashboardGranularity = (typeof DASHBOARD_GRANULARITIES)[number]; function pad2(n: number): string { return String(n).padStart(2, '0'); } export function shanghaiQuarterIndex(d: Date): { year: number; quarter: number } { const [y, m] = shanghaiYmd(d).split('-').map(Number); return { year: y, quarter: Math.ceil(m / 3) }; } export function shanghaiQuarterRange( year: number, quarter: number, ): { start: Date; endExclusive: Date } { const startMonth = (quarter - 1) * 3 + 1; const endMonth = startMonth + 3; const endYear = endMonth > 12 ? year + 1 : year; const em = endMonth > 12 ? endMonth - 12 : endMonth; return { start: new Date(`${year}-${pad2(startMonth)}-01T00:00:00+08:00`), endExclusive: new Date(`${endYear}-${pad2(em)}-01T00:00:00+08:00`), }; } export function shanghaiBucketKey(d: Date, grain: DashboardGranularity): string { const ymd = shanghaiYmd(d); const [y, m] = ymd.split('-').map(Number); switch (grain) { case 'day': return ymd; case 'week': return shanghaiYmd(shanghaiWeekRange(d).start); case 'month': return `${y}-${pad2(m)}`; case 'quarter': return `${y}-Q${Math.ceil(m / 3)}`; case 'year': return String(y); } } export function shanghaiBucketLabel(key: string, grain: DashboardGranularity): string { switch (grain) { case 'day': return key.slice(5); case 'week': return `${key.slice(5)}周`; default: return key; } } export type ShanghaiBucket = { key: string; label: string; start: Date; endExclusive: Date; }; export function eachShanghaiBuckets( from: Date, to: Date, grain: DashboardGranularity, ): ShanghaiBucket[] { const startDay = startOfShanghaiDay(from); const endDay = startOfShanghaiDay(to); const out: ShanghaiBucket[] = []; if (grain === 'day') { for (let d = startDay; d.getTime() <= endDay.getTime(); d = addShanghaiDays(d, 1)) { const key = shanghaiYmd(d); out.push({ key, label: shanghaiBucketLabel(key, grain), start: d, endExclusive: addShanghaiDays(d, 1), }); } return out; } if (grain === 'week') { let { start } = shanghaiWeekRange(startDay); const lastStart = shanghaiWeekRange(endDay).start; while (start.getTime() <= lastStart.getTime()) { const key = shanghaiYmd(start); const endExclusive = addShanghaiDays(start, 7); out.push({ key, label: shanghaiBucketLabel(key, grain), start, endExclusive }); start = endExclusive; } return out; } if (grain === 'month') { let { year, month } = shanghaiYearMonth(startDay); const end = shanghaiYearMonth(endDay); while (year < end.year || (year === end.year && month <= end.month)) { const range = shanghaiMonthRange(year, month); const key = `${year}-${pad2(month)}`; out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive }); month += 1; if (month > 12) { month = 1; year += 1; } } return out; } if (grain === 'quarter') { let { year, quarter } = shanghaiQuarterIndex(startDay); const end = shanghaiQuarterIndex(endDay); while (year < end.year || (year === end.year && quarter <= end.quarter)) { const range = shanghaiQuarterRange(year, quarter); const key = `${year}-Q${quarter}`; out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive }); quarter += 1; if (quarter > 4) { quarter = 1; year += 1; } } return out; } let year = Number(shanghaiYmd(startDay).slice(0, 4)); const endYear = Number(shanghaiYmd(endDay).slice(0, 4)); while (year <= endYear) { out.push({ key: String(year), label: String(year), start: new Date(`${year}-01-01T00:00:00+08:00`), endExclusive: new Date(`${year + 1}-01-01T00:00:00+08:00`), }); year += 1; } return out; } /** 按粒度把日期往前挪一档(日/周/月/季/年),用于环比窗口 */ export function addShanghaiGrain(d: Date, grain: DashboardGranularity, delta: number): Date { const start = startOfShanghaiDay(d); switch (grain) { case 'day': return addShanghaiDays(start, delta); case 'week': return addShanghaiDays(start, delta * 7); case 'month': return addShanghaiMonths(start, delta); case 'quarter': return addShanghaiMonths(start, delta * 3); case 'year': return addShanghaiMonths(start, delta * 12); } } function addShanghaiMonths(d: Date, delta: number): Date { const [y, m, day] = shanghaiYmd(d).split('-').map(Number); const utc = new Date(Date.UTC(y, m - 1 + delta, 1)); const ty = utc.getUTCFullYear(); const tm = utc.getUTCMonth() + 1; const last = new Date(Date.UTC(ty, tm, 0)).getUTCDate(); return parseShanghaiYmd(`${ty}-${pad2(tm)}-${pad2(Math.min(day, last))}`); } /** 整段起止按粒度回退一档:日窗口昨天~今天 → 前天~昨天 */ export function previousShanghaiRangeByGrain( from: Date, to: Date, grain: DashboardGranularity, ): { from: Date; to: Date } { return { from: addShanghaiGrain(from, grain, -1), to: addShanghaiGrain(to, grain, -1), }; } /** 环比:(本期 − 上期) / 上期;上期 0 且本期 0 → null;上期 0 且本期 > 0 → 1 */ export function periodMomRatio(curr: number, prev: number): number | null { if (prev === 0) return curr === 0 ? null : 1; return (curr - prev) / prev; } /** 默认窗口 = 上一档起点 ~ 今天(日=昨天~今天,周=上周一~今天,以此类推) */ export function defaultShanghaiRange( grain: DashboardGranularity, anchor = new Date(), ): { from: Date; to: Date } { const to = startOfShanghaiDay(anchor); switch (grain) { case 'day': return { from: addShanghaiDays(to, -1), to }; case 'week': { const thisMonday = shanghaiWeekRange(to).start; return { from: addShanghaiDays(thisMonday, -7), to }; } case 'month': { const { year, month } = shanghaiYearMonth(to); const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 }; return { from: shanghaiMonthRange(prev.year, prev.month).start, to }; } case 'quarter': { const { year, quarter } = shanghaiQuarterIndex(to); let y = year; let q = quarter - 1; if (q <= 0) { q = 4; y -= 1; } return { from: shanghaiQuarterRange(y, q).start, to }; } case 'year': { const y = Number(shanghaiYmd(to).slice(0, 4)); return { from: new Date(`${y - 1}-01-01T00:00:00+08:00`), to }; } } } export function defaultShanghaiRangeYmds( grain: DashboardGranularity, anchor = new Date(), ): { from: string; to: string } { const r = defaultShanghaiRange(grain, anchor); return { from: shanghaiYmd(r.from), to: shanghaiYmd(r.to) }; }