PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / lib / report-series.ts

report-series.ts in Yatra – Travel Booking & Tour Operator Software 3.0.11, at resources/js/lib/report-series.ts

265 lines 7.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Reusable series-bucketing helpers for the Reports admin page.
3 *
4 * The /reports endpoint emits day-level trend arrays with both an ISO
5 * `date` and a human `label`:
6 *
7 * [{ date: "2025-11-01", label: "1 Nov", value: 5 }, ...]
8 *
9 * The UI surfaces three view types — daily / weekly / monthly. Rather
10 * than ask the backend for three different shapes (and pay three
11 * roundtrips when the user toggles the dropdown), we receive day-level
12 * data once and re-bucket it client-side.
13 *
14 * Why ISO weeks: locale-independent week numbering survives DST jumps
15 * and year boundaries. Operators in Sydney see the same Monday-anchored
16 * week as operators in Lisbon — the chart they share over email won't
17 * misalign by a day. Months use the local year+month-of-year string.
18 *
19 * Bucketing strategy:
20 * - daily: 1-to-1 passthrough
21 * - weekly: aggregate by ISO week, label "W{N} {short month}"
22 * - monthly: aggregate by year+month, label "{Mon} {YYYY}"
23 *
24 * @since 3.0.5
25 */
26
27 export type TrendView = "daily" | "weekly" | "monthly";
28
29 export interface SeriesPoint {
30 date: string; // YYYY-MM-DD
31 label: string;
32 value: number;
33 }
34
35 /** Status-split row that the controller's `status_trend` returns. */
36 export interface StatusPoint {
37 date: string;
38 label: string;
39 confirmed: number;
40 pending: number;
41 cancelled: number;
42 completed: number;
43 }
44
45 interface ISOWeek {
46 year: number;
47 week: number;
48 }
49
50 /**
51 * ISO week number of a date. Returns 1..53. Mirrors the SQL
52 * WEEK(date, 3) mode WordPress uses elsewhere.
53 */
54 function isoWeek(d: Date): ISOWeek {
55 // Copy so we don't mutate the input.
56 const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
57 // ISO week starts Monday. Shift the target to the Thursday of its
58 // week — that Thursday's year is the ISO year.
59 const day = target.getUTCDay() || 7;
60 target.setUTCDate(target.getUTCDate() + 4 - day);
61 const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
62 const diff = (target.getTime() - firstThursday.getTime()) / 86400000;
63 return {
64 year: target.getUTCFullYear(),
65 week: 1 + Math.floor(diff / 7),
66 };
67 }
68
69 function parseISO(date: string): Date | null {
70 if (!date) return null;
71 // The backend hands us "YYYY-MM-DD". Parse as UTC to avoid TZ drift
72 // on the day boundary — a midnight-local-time parse would land on
73 // the wrong day for negative offsets.
74 const [y, m, d] = date.split("-").map((s) => parseInt(s, 10));
75 if (!y || !m || !d) return null;
76 return new Date(Date.UTC(y, m - 1, d));
77 }
78
79 const MONTH_LABELS = [
80 "Jan",
81 "Feb",
82 "Mar",
83 "Apr",
84 "May",
85 "Jun",
86 "Jul",
87 "Aug",
88 "Sep",
89 "Oct",
90 "Nov",
91 "Dec",
92 ];
93
94 /**
95 * Aggregate a day-level series into the requested view.
96 *
97 * Behaviour:
98 * - daily: passthrough (already day-aligned by the backend).
99 * - weekly: sum values per ISO week. Label = "W{n} Nov".
100 * - monthly: sum values per calendar month. Label = "Nov 2025".
101 *
102 * @param points day-level series from `/reports`
103 * @param view daily | weekly | monthly
104 */
105 export function bucketSeries(
106 points: SeriesPoint[],
107 view: TrendView,
108 ): SeriesPoint[] {
109 if (view === "daily" || points.length === 0) {
110 return points;
111 }
112
113 if (view === "weekly") {
114 const buckets = new Map<string, SeriesPoint>();
115 for (const p of points) {
116 const d = parseISO(p.date);
117 if (!d) continue;
118 const { year, week } = isoWeek(d);
119 const key = `${year}-W${week.toString().padStart(2, "0")}`;
120 const existing = buckets.get(key);
121 if (existing) {
122 existing.value += p.value;
123 } else {
124 buckets.set(key, {
125 date: p.date, // first-day-in-week anchor; UI rarely needs it
126 label: `W${week} ${MONTH_LABELS[d.getUTCMonth()]}`,
127 value: p.value,
128 });
129 }
130 }
131 return Array.from(buckets.values());
132 }
133
134 // monthly
135 const buckets = new Map<string, SeriesPoint>();
136 for (const p of points) {
137 const d = parseISO(p.date);
138 if (!d) continue;
139 const key = `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1)
140 .toString()
141 .padStart(2, "0")}`;
142 const existing = buckets.get(key);
143 if (existing) {
144 existing.value += p.value;
145 } else {
146 buckets.set(key, {
147 date: p.date,
148 label: `${MONTH_LABELS[d.getUTCMonth()]} ${d.getUTCFullYear()}`,
149 value: p.value,
150 });
151 }
152 }
153 return Array.from(buckets.values());
154 }
155
156 /**
157 * Same shape as bucketSeries but for `status_trend`: aggregates
158 * confirmed/pending/cancelled/completed across the bucket.
159 */
160 export function bucketStatusSeries(
161 points: StatusPoint[],
162 view: TrendView,
163 ): StatusPoint[] {
164 if (view === "daily" || points.length === 0) {
165 return points;
166 }
167
168 const keyFn = (d: Date): { key: string; label: string } => {
169 if (view === "weekly") {
170 const { year, week } = isoWeek(d);
171 return {
172 key: `${year}-W${week.toString().padStart(2, "0")}`,
173 label: `W${week} ${MONTH_LABELS[d.getUTCMonth()]}`,
174 };
175 }
176 return {
177 key: `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1)
178 .toString()
179 .padStart(2, "0")}`,
180 label: `${MONTH_LABELS[d.getUTCMonth()]} ${d.getUTCFullYear()}`,
181 };
182 };
183
184 const buckets = new Map<string, StatusPoint>();
185 for (const p of points) {
186 const d = parseISO(p.date);
187 if (!d) continue;
188 const { key, label } = keyFn(d);
189 const existing = buckets.get(key);
190 if (existing) {
191 existing.confirmed += p.confirmed;
192 existing.pending += p.pending;
193 existing.cancelled += p.cancelled;
194 existing.completed += p.completed;
195 } else {
196 buckets.set(key, {
197 date: p.date,
198 label,
199 confirmed: p.confirmed,
200 pending: p.pending,
201 cancelled: p.cancelled,
202 completed: p.completed,
203 });
204 }
205 }
206 return Array.from(buckets.values());
207 }
208
209 /**
210 * CSV builder used by the Reports export button.
211 *
212 * - Escapes per RFC 4180: wraps any field containing quote, comma or
213 * newline in double-quotes, with embedded quotes doubled.
214 * - Uses CRLF row endings (Excel-friendly).
215 * - Returns a Blob ready for <a download>.
216 *
217 * Don't add a UTF-8 BOM — Excel-for-Windows-prior-to-2016 wants one but
218 * every modern build of Excel + Numbers + LibreOffice handles UTF-8 fine.
219 * If an operator hits the older Excel, opening via Data → From Text works.
220 */
221 export function buildCsv(rows: (string | number | null | undefined)[][]): Blob {
222 const escape = (v: string | number | null | undefined): string => {
223 const s = v == null ? "" : String(v);
224 if (/[",\r\n]/.test(s)) {
225 return `"${s.replace(/"/g, '""')}"`;
226 }
227 return s;
228 };
229 const body = rows.map((r) => r.map(escape).join(",")).join("\r\n");
230 return new Blob([body], { type: "text/csv;charset=utf-8" });
231 }
232
233 /**
234 * Trigger a browser download for a CSV blob. Hidden anchor + click()
235 * pattern — works on every supported browser without any libs.
236 */
237 export function downloadCsv(blob: Blob, filename: string): void {
238 const url = URL.createObjectURL(blob);
239 const a = document.createElement("a");
240 a.href = url;
241 a.download = filename;
242 a.style.display = "none";
243 document.body.appendChild(a);
244 a.click();
245 document.body.removeChild(a);
246 // Defer revoke so single-process browsers (older Safari) finish the
247 // download dialog before the URL is invalidated.
248 setTimeout(() => URL.revokeObjectURL(url), 500);
249 }
250
251 /**
252 * Build a deterministic filename for a CSV export.
253 * Example: yatra-bookings-2025-11-01-2025-11-30.csv
254 */
255 export function csvFilename(slug: string, from?: string, to?: string): string {
256 if (from && to) {
257 return `yatra-${slug}-${from}-to-${to}.csv`;
258 }
259 // No range supplied — fall back to today's date so the file still
260 // tells a future-you which day it was exported.
261 const now = new Date();
262 const today = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`;
263 return `yatra-${slug}-${today}.csv`;
264 }
265