Skip to content

Commit 5c069e0

Browse files
feat: all time range
Signed-off-by: Henry Gressmann <[email protected]>
1 parent 5fad021 commit 5c069e0

File tree

10 files changed

+128
-13
lines changed

10 files changed

+128
-13
lines changed

src/app/core/reports.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,31 @@ fn metric_sql(metric: Metric) -> String {
211211
Metric::AvgTimeOnSite => {
212212
// avg time_until_next_event where time_until_next_event <= 1800 and time_until_next_event is not null
213213
"--sql
214-
avg(sd.time_until_next_event) filter (where sd.time_until_next_event is not null and sd.time_until_next_event <= 1800)"
214+
coalesce(avg(sd.time_until_next_event) filter (where sd.time_until_next_event is not null and sd.time_until_next_event <= 1800), 0)"
215215
}
216216
}
217217
.to_owned()
218218
}
219219

220+
pub fn earliest_timestamp(conn: &DuckDBConn, entities: &[String]) -> Result<Option<time::OffsetDateTime>> {
221+
if entities.is_empty() {
222+
return Ok(None);
223+
}
224+
225+
let vars = repeat_vars(entities.len());
226+
let query = format!(
227+
"--sql
228+
select min(created_at) from events
229+
where entity_id in ({vars});
230+
"
231+
);
232+
233+
let mut stmt = conn.prepare_cached(&query)?;
234+
let rows = stmt.query_map(params_from_iter(entities), |row| row.get(0))?;
235+
let earliest_timestamp = rows.collect::<Result<Vec<Option<time::OffsetDateTime>>, duckdb::Error>>()?;
236+
Ok(earliest_timestamp[0])
237+
}
238+
220239
pub fn online_users(conn: &DuckDBConn, entities: &[String]) -> Result<u64> {
221240
if entities.is_empty() {
222241
return Ok(0);

src/web/routes/dashboard.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ pub struct DashboardAPI;
6666

6767
#[OpenApi]
6868
impl DashboardAPI {
69+
#[oai(path = "/project/:project_id/earliest", method = "get")]
70+
async fn project_earliest_handler(
71+
&self,
72+
Path(project_id): Path<String>,
73+
Data(app): Data<&Liwan>,
74+
user: Option<SessionUser>,
75+
) -> ApiResult<Json<Option<time::OffsetDateTime>>> {
76+
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
77+
78+
if !can_access_project(&project, user.as_ref()) {
79+
http_bail!(StatusCode::NOT_FOUND, "Project not found")
80+
}
81+
82+
let conn = app.events_conn().http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
83+
let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
84+
let earliest = reports::earliest_timestamp(&conn, &entities).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
85+
Ok(Json(earliest))
86+
}
87+
6988
#[oai(path = "/project/:project_id/graph", method = "post")]
7089
async fn project_graph_handler(
7190
&self,

web/src/api/dashboard.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

web/src/api/ranges.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
addYears,
88
differenceInDays,
99
differenceInHours,
10+
differenceInMonths,
1011
differenceInSeconds,
1112
differenceInYears,
1213
endOfDay,
@@ -37,11 +38,11 @@ type DateRangeValue = { start: Date; end: Date };
3738

3839
export class DateRange {
3940
#value: RangeName | { start: Date; end: Date };
40-
label: string;
41+
variant?: string;
4142

4243
constructor(value: RangeName | { start: Date; end: Date }) {
4344
this.#value = value;
44-
this.label = "";
45+
if (typeof value === "string") this.variant = value;
4546
}
4647

4748
get value(): DateRangeValue {
@@ -52,10 +53,11 @@ export class DateRange {
5253
}
5354

5455
isCustom(): boolean {
55-
return typeof this.#value !== "string";
56+
return typeof this.#value !== "string" && !this.variant;
5657
}
5758

5859
format(): string {
60+
if (this.variant === "allTime") return "All Time";
5961
if (typeof this.#value === "string") return wellKnownRanges[this.#value];
6062
return formatDateRange(this.#value.start, this.#value.end);
6163
}
@@ -66,15 +68,19 @@ export class DateRange {
6668

6769
serialize(): string {
6870
if (typeof this.#value === "string") return this.#value;
69-
return `${Number(this.#value.start)}:${Number(this.#value.end)}`;
71+
return `${Number(this.#value.start)}:${Number(this.#value.end)}:${this.variant}`;
7072
}
7173

7274
static deserialize(range: string): DateRange {
7375
if (!range.includes(":")) {
7476
return new DateRange(range as RangeName);
7577
}
76-
const [start, end] = range.split(":").map((v) => new Date(Number(v)));
77-
return new DateRange({ start, end });
78+
const [start, end, variant] = range.split(":");
79+
const dr = new DateRange({ start: new Date(Number(start)), end: new Date(Number(end)) });
80+
if (variant) {
81+
dr.variant = variant;
82+
}
83+
return dr;
7884
}
7985

8086
endsToday(): boolean {
@@ -118,6 +124,7 @@ export class DateRange {
118124
}
119125

120126
previous() {
127+
if (this.variant === "allTime") return this;
121128
if (this.#value === "today") return new DateRange("yesterday");
122129

123130
if (
@@ -150,6 +157,18 @@ export class DateRange {
150157
return new DateRange({ start, end });
151158
}
152159

160+
if (differenceInMonths(this.value.end, this.value.start) === 12) {
161+
// if (isSameDay(this.value.start, startOfMonth(this.value.start))) {
162+
// const start = startOfMonth(subYears(this.value.start, 1));
163+
// const end = endOfMonth(subYears(this.value.end, 1));
164+
// return new DateRange({ start, end });
165+
// }
166+
167+
const start = subYears(this.value.start, 1);
168+
const end = subYears(this.value.end, 1);
169+
return new DateRange({ start, end });
170+
}
171+
153172
if (differenceInHours(this.value.end, this.value.start) < 23) {
154173
const start = subSeconds(this.value.start, differenceInSeconds(this.value.end, this.value.start));
155174
const end = subSeconds(this.value.end, differenceInSeconds(this.value.end, this.value.start));
@@ -198,6 +217,18 @@ export class DateRange {
198217
return new DateRange({ start, end });
199218
}
200219

220+
if (differenceInMonths(this.value.end, this.value.start) === 12) {
221+
// if (isSameDay(this.value.start, startOfMonth(this.value.start))) {
222+
// const start = startOfMonth(addYears(this.value.start, 1));
223+
// const end = endOfMonth(addYears(this.value.end, 1));
224+
// return new DateRange({ start, end });
225+
// }
226+
227+
const start = addYears(this.value.start, 1);
228+
const end = addYears(this.value.end, 1);
229+
return new DateRange({ start, end });
230+
}
231+
201232
if (differenceInHours(this.value.end, this.value.start) < 23) {
202233
const start = addSeconds(this.value.start, differenceInSeconds(this.value.end, this.value.start));
203234
const end = addSeconds(this.value.end, differenceInSeconds(this.value.end, this.value.start));
@@ -247,8 +278,8 @@ export const ranges: Record<RangeName, () => { range: { start: Date; end: Date }
247278
last7Days: () => ({ range: lastXDays(7) }),
248279
last30Days: () => ({ range: lastXDays(30) }),
249280
last12Months: () => {
281+
const start = startOfMonth(subYears(new Date(), 1));
250282
const end = endOfMonth(new Date());
251-
const start = subMonths(end, 11);
252283
return { range: { start, end } };
253284
},
254285
weekToDate: () => {

web/src/components/project.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const Project = () => {
9393
<div>
9494
<div className={styles.projectHeader}>
9595
<ProjectHeader project={project} stats={stats.data} />
96-
<SelectRange onSelect={(range) => setRangeString(range.serialize())} range={range} />
96+
<SelectRange onSelect={(range) => setRangeString(range.serialize())} range={range} projectId={project.id} />
9797
</div>
9898
<SelectMetrics data={stats.data} metric={metric} setMetric={setMetric} className={styles.projectStats} />
9999
<SelectFilters value={filters} onChange={setFilters} />

web/src/components/project/range.module.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
color: var(--pico-h1-background-color);
1616
}
1717
}
18+
19+
ul {
20+
li {
21+
padding: 0;
22+
23+
button {
24+
box-sizing: border-box;
25+
width: 100%;
26+
padding: 0.4rem var(--pico-form-element-spacing-horizontal);
27+
}
28+
}
29+
}
1830
}
1931

2032
details.selectRange {

web/src/components/project/range.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,38 @@ import { cls } from "../../utils";
77
import { Dialog } from "../dialog";
88
import { DatePickerRange } from "../daterange";
99
import { DateRange, wellKnownRanges, type RangeName } from "../../api/ranges";
10+
import { api, useQuery } from "../../api";
11+
import { endOfDay, startOfDay } from "date-fns";
1012

11-
export const SelectRange = ({ onSelect, range }: { onSelect: (range: DateRange) => void; range: DateRange }) => {
13+
export const SelectRange = ({
14+
onSelect,
15+
range,
16+
projectId,
17+
}: { onSelect: (range: DateRange) => void; range: DateRange; projectId?: string }) => {
1218
const detailsRef = useRef<HTMLDetailsElement>(null);
1319

1420
const handleSelect = (range: DateRange) => () => {
1521
if (detailsRef.current) detailsRef.current.open = false;
1622
onSelect(range);
1723
};
1824

25+
const allTime = useQuery({
26+
queryKey: ["allTime", projectId],
27+
enabled: !!projectId,
28+
staleTime: 7 * 24 * 60 * 60 * 1000,
29+
queryFn: () =>
30+
api["/api/dashboard/project/{project_id}/earliest"].get({ params: { project_id: projectId || "" } }).json(),
31+
});
32+
33+
const selectAllTime = async () => {
34+
if (!projectId) return;
35+
if (!allTime.data) return;
36+
const from = new Date(allTime.data);
37+
const range = new DateRange({ start: startOfDay(from), end: endOfDay(new Date()) });
38+
range.variant = "allTime";
39+
onSelect(range);
40+
};
41+
1942
return (
2043
<div className={styles.container}>
2144
<button type="button" className="secondary" onClick={handleSelect(range.previous())}>
@@ -38,6 +61,17 @@ export const SelectRange = ({ onSelect, range }: { onSelect: (range: DateRange)
3861
</button>
3962
</li>
4063
))}
64+
{projectId && allTime.data && (
65+
<li>
66+
<button
67+
type="button"
68+
className={range.variant === "allTime" ? styles.selected : ""}
69+
onClick={selectAllTime}
70+
>
71+
All Time
72+
</button>
73+
</li>
74+
)}
4175
<li>
4276
<Dialog
4377
className={styles.rangeDialog}

web/src/components/projects.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ const NoProjects = () => {
4141
export const Projects = () => {
4242
const { data, isLoading, isError } = useQuery({
4343
queryKey: ["projects"],
44-
4544
queryFn: () => api["/api/dashboard/projects"].get().json(),
4645
});
4746

web/src/components/worldmap.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export const WorldMap = ({
8484
<div className={styles.tooltip} data-theme="dark">
8585
<h2>{metricNames[metric]}</h2>
8686
<h3>
87-
{currentGeo.name} <span>{formatMetricVal(countries.get(currentGeo.iso) ?? 0)}</span>
87+
{currentGeo.name} <span>{formatMetricVal(countries.get(currentGeo.iso) ?? 0, metric)}</span>
8888
</h3>
8989
</div>
9090
)}

web/src/global.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif,
2323
var(--pico-font-family-emoji);
2424
--pico-font-family-sans-serif: var(--pico-font-family);
25+
font-variant-numeric: tabular-nums;
2526
}
2627

2728
:root[data-theme="dark"] {

0 commit comments

Comments
 (0)