Skip to content

Commit c6236c3

Browse files
vcarlDanielFGray
andauthored
Pull in Star Helper cohort analysis work from DFG (#226)
I meant to build more on top of this before merging, but this is now beginning to risk rework due to merge conflicts and it's in a workable state, so I'm going to merge. Thank you @DanielFGray for the bulk of this work! The Star Hunter feature is going to be gated at launch as a future premium feature — it currently relies on hardcoded channels from Reactiflux, which obviously is not workable as a production feature, and revising it to handle advance configuration/work delegation is more effort than I want to gate further development on. So tl;dr - Star Hunter feature works for Reactiflux (only) - Intended in the future as a premium add-on feature, available to paid plans - Merging this so it can continue to be built upon --------- Co-authored-by: DanielFGray <danielfgray@gmail.com>
1 parent 8a0e9ee commit c6236c3

File tree

6 files changed

+896
-75
lines changed

6 files changed

+896
-75
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import {
2+
addDays,
3+
addMonths,
4+
addYears,
5+
endOfQuarter,
6+
endOfYear,
7+
format,
8+
startOfQuarter,
9+
startOfYear,
10+
subDays,
11+
} from "date-fns";
12+
import { useState, type LabelHTMLAttributes } from "react";
13+
14+
const Label = (props: LabelHTMLAttributes<Element>) => (
15+
<label {...props} className={`${props.className ?? ""} m-4`}>
16+
{props.children}
17+
</label>
18+
);
19+
20+
const DATE_FORMAT = "yyyy-MM-dd";
21+
const DISPLAY_FORMAT = "MMM d, yyyy";
22+
23+
export type PresetKey = "30d" | "quarter" | "half" | "year" | "custom";
24+
25+
const presets: { key: PresetKey; label: string }[] = [
26+
{ key: "30d", label: "30 days" },
27+
{ key: "quarter", label: "Quarter" },
28+
{ key: "half", label: "6 months" },
29+
{ key: "year", label: "Year" },
30+
{ key: "custom", label: "Custom" },
31+
];
32+
33+
// Get start of the half-year (H1: Jan-Jun, H2: Jul-Dec)
34+
function startOfHalf(date: Date): Date {
35+
const month = date.getMonth();
36+
const year = date.getFullYear();
37+
return month < 6 ? new Date(year, 0, 1) : new Date(year, 6, 1);
38+
}
39+
40+
function endOfHalf(date: Date): Date {
41+
const month = date.getMonth();
42+
const year = date.getFullYear();
43+
return month < 6 ? new Date(year, 5, 30) : new Date(year, 11, 31);
44+
}
45+
46+
function shiftDatesByInterval(
47+
start: Date,
48+
_end: Date,
49+
interval: Exclude<PresetKey, "custom">,
50+
direction: "back" | "forward",
51+
): { start: string; end: string } {
52+
const shift = direction === "back" ? -1 : 1;
53+
54+
switch (interval) {
55+
case "30d":
56+
return {
57+
start: format(addDays(start, shift * 30), DATE_FORMAT),
58+
end: format(addDays(start, shift * 30 + 29), DATE_FORMAT),
59+
};
60+
case "quarter": {
61+
const newStart = startOfQuarter(addMonths(start, shift * 3));
62+
return {
63+
start: format(newStart, DATE_FORMAT),
64+
end: format(endOfQuarter(newStart), DATE_FORMAT),
65+
};
66+
}
67+
case "half": {
68+
const newStart = startOfHalf(addMonths(start, shift * 6));
69+
return {
70+
start: format(newStart, DATE_FORMAT),
71+
end: format(endOfHalf(newStart), DATE_FORMAT),
72+
};
73+
}
74+
case "year": {
75+
const newStart = startOfYear(addYears(start, shift));
76+
return {
77+
start: format(newStart, DATE_FORMAT),
78+
end: format(endOfYear(newStart), DATE_FORMAT),
79+
};
80+
}
81+
}
82+
}
83+
84+
function getPresetDates(key: Exclude<PresetKey, "custom">): {
85+
start: string;
86+
end: string;
87+
} {
88+
const today = new Date();
89+
90+
switch (key) {
91+
case "30d":
92+
return {
93+
start: format(subDays(today, 30), DATE_FORMAT),
94+
end: format(today, DATE_FORMAT),
95+
};
96+
case "quarter":
97+
return {
98+
start: format(startOfQuarter(today), DATE_FORMAT),
99+
end: format(endOfQuarter(today), DATE_FORMAT),
100+
};
101+
case "half":
102+
return {
103+
start: format(startOfHalf(today), DATE_FORMAT),
104+
end: format(endOfHalf(today), DATE_FORMAT),
105+
};
106+
case "year":
107+
return {
108+
start: format(startOfYear(today), DATE_FORMAT),
109+
end: format(endOfYear(today), DATE_FORMAT),
110+
};
111+
}
112+
}
113+
114+
function navigateTo(start: string, end: string, interval?: PresetKey) {
115+
const url = new URL(window.location.href);
116+
url.searchParams.set("start", start);
117+
url.searchParams.set("end", end);
118+
if (interval) {
119+
url.searchParams.set("interval", interval);
120+
} else {
121+
url.searchParams.delete("interval");
122+
}
123+
window.location.href = url.toString();
124+
}
125+
126+
export function RangeForm({
127+
values,
128+
interval: currentInterval,
129+
}: {
130+
values: { start?: string; end?: string };
131+
interval?: PresetKey;
132+
}) {
133+
const [showCustom, setShowCustom] = useState(currentInterval === "custom");
134+
const [selectedInterval, setSelectedInterval] = useState<PresetKey>(
135+
currentInterval ?? "30d",
136+
);
137+
138+
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
139+
const key = e.target.value as PresetKey;
140+
setSelectedInterval(key);
141+
if (key === "custom") {
142+
setShowCustom(true);
143+
return;
144+
}
145+
setShowCustom(false);
146+
const { start, end } = getPresetDates(key);
147+
navigateTo(start, end, key);
148+
};
149+
150+
const handleNav = (direction: "back" | "forward") => {
151+
if (!values.start || !values.end || selectedInterval === "custom") return;
152+
const { start, end } = shiftDatesByInterval(
153+
new Date(values.start),
154+
new Date(values.end),
155+
selectedInterval,
156+
direction,
157+
);
158+
navigateTo(start, end, selectedInterval);
159+
};
160+
161+
const rangeLabel =
162+
values.start && values.end
163+
? `${format(new Date(values.start), DISPLAY_FORMAT)}${format(new Date(values.end), DISPLAY_FORMAT)}`
164+
: null;
165+
166+
const canNavigate =
167+
values.start && values.end && selectedInterval !== "custom";
168+
169+
return (
170+
<div className="flex flex-col items-center gap-3">
171+
<div className="flex items-center gap-3">
172+
<select
173+
onChange={handlePresetChange}
174+
value={selectedInterval}
175+
className="rounded border border-gray-300 px-3 py-1.5 text-gray-800"
176+
>
177+
{presets.map((p) => (
178+
<option key={p.key} value={p.key}>
179+
{p.label}
180+
</option>
181+
))}
182+
</select>
183+
184+
{canNavigate && (
185+
<div className="flex items-center gap-1">
186+
<button
187+
type="button"
188+
onClick={() => handleNav("back")}
189+
className="rounded border border-gray-300 px-2 py-1 hover:bg-gray-100"
190+
aria-label="Previous period"
191+
>
192+
193+
</button>
194+
<button
195+
type="button"
196+
onClick={() => handleNav("forward")}
197+
className="rounded border border-gray-300 px-2 py-1 hover:bg-gray-100"
198+
aria-label="Next period"
199+
>
200+
201+
</button>
202+
</div>
203+
)}
204+
</div>
205+
206+
{rangeLabel && <div className="text-sm text-gray-600">{rangeLabel}</div>}
207+
208+
{showCustom && (
209+
<form method="GET" className="flex items-end gap-2">
210+
<Label className="m-0">
211+
Start
212+
<input
213+
name="start"
214+
type="date"
215+
defaultValue={values.start}
216+
className="ml-2 rounded border border-gray-300 px-2 py-1 text-gray-800"
217+
/>
218+
</Label>
219+
<Label className="m-0">
220+
End
221+
<input
222+
name="end"
223+
type="date"
224+
defaultValue={values.end}
225+
className="ml-2 rounded border border-gray-300 px-2 py-1 text-gray-800"
226+
/>
227+
</Label>
228+
<input type="hidden" name="interval" value="custom" />
229+
<button
230+
type="submit"
231+
className="rounded bg-blue-600 px-4 py-1.5 text-white hover:bg-blue-700"
232+
>
233+
Apply
234+
</button>
235+
</form>
236+
)}
237+
</div>
238+
);
239+
}

0 commit comments

Comments
 (0)