Skip to content

Commit b7e9728

Browse files
committed
Add some public stats!
1 parent ce76d74 commit b7e9728

File tree

14 files changed

+4296
-4654
lines changed

14 files changed

+4296
-4654
lines changed

app/src/api/routes.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
use crate::api::types::EventResponse;
1+
use crate::api::types::{CalendarStatsResponse, EventResponse};
22
use crate::db::{Calendar, DbState};
33
use crate::processing::rule::Rule;
44
use crate::upstream;
5+
use axum::Json;
56
use axum::body::Body;
67
use axum::extract::{Path, State};
7-
use axum::http::{header, HeaderMap, StatusCode};
8+
use axum::http::{HeaderMap, StatusCode, header};
89
use axum::response::IntoResponse;
9-
use axum::Json;
1010
use serde::{Deserialize, Serialize};
11+
use std::time::UNIX_EPOCH;
1112
use tokio_util::io::ReaderStream;
1213
use utoipa::ToSchema;
1314
use utoipa_axum::router::OpenApiRouter;
@@ -32,6 +33,7 @@ pub(crate) fn router() -> axum::Router<DbState> {
3233
.routes(routes!(allowlist_add))
3334
.routes(routes!(allowlist_remove))
3435
.routes(routes!(allowlist_list))
36+
.routes(routes!(get_stats))
3537
.split_for_parts();
3638
app_router
3739
.route("/calendars/{id}/feed", axum::routing::get(get_feed))
@@ -99,7 +101,7 @@ pub async fn get_events(
99101
State(db): State<DbState>,
100102
Path(id): Path<String>,
101103
) -> Result<Json<Vec<EventResponse>>, StatusCode> {
102-
let db_lock = db.lock().await;
104+
let mut db_lock = db.lock().await;
103105
let cal = db_lock
104106
.get_calendar(&id)
105107
.await
@@ -234,13 +236,19 @@ pub async fn get_feed(
234236
State(db): State<DbState>,
235237
Path(id): Path<String>,
236238
) -> Result<(HeaderMap, Body), StatusCode> {
237-
let db_lock = db.lock().await;
238-
let calendar = db_lock
239+
let mut db_lock = db.lock().await;
240+
let mut calendar = db_lock
239241
.get_calendar(&id)
240242
.await
241243
.ok_or(StatusCode::NOT_FOUND)?;
242244
drop(db_lock);
243245

246+
calendar.last_accessed = Some(
247+
std::time::SystemTime::now()
248+
.duration_since(std::time::UNIX_EPOCH)
249+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
250+
.as_secs(),
251+
);
244252
let ical = calendar.get_filtered_icalendar();
245253

246254
let reader = std::io::Cursor::new(ical.to_string().into_bytes());
@@ -557,3 +565,41 @@ pub async fn allowlist_list(
557565

558566
Ok(Json(set))
559567
}
568+
569+
#[utoipa::path(
570+
get,
571+
path = "/stats",
572+
responses(
573+
(status = 200, description = "Retrieved calendar statistics", body = CalendarStatsResponse),
574+
)
575+
)]
576+
pub async fn get_stats(
577+
State(db): State<DbState>,
578+
) -> Result<Json<CalendarStatsResponse>, StatusCode> {
579+
let db_lock = db.lock().await;
580+
let mut active_calendars = 0;
581+
let calendars = db_lock.list_calendars().await;
582+
583+
for calendar in calendars {
584+
let Some(last_accessed) = calendar.last_accessed else {
585+
continue;
586+
};
587+
let Ok(duration_since_last_access) = std::time::SystemTime::now()
588+
.duration_since(UNIX_EPOCH + std::time::Duration::from_secs(last_accessed))
589+
.map_err(|err| {
590+
eprintln!("Error calculating duration since last access: {}", err);
591+
()
592+
})
593+
else {
594+
continue;
595+
};
596+
597+
if duration_since_last_access < std::time::Duration::from_secs(2 * 24 * 60 * 60) {
598+
active_calendars += 1;
599+
}
600+
}
601+
602+
let stats = CalendarStatsResponse { active_calendars };
603+
604+
Ok(Json(stats))
605+
}

app/src/api/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ pub struct EventResponse {
1515
/// Whether this event is allowlisted (immune from rule blocking)
1616
pub manually_allowlisted: bool,
1717
}
18+
19+
#[derive(Serialize, ToSchema)]
20+
pub struct CalendarStatsResponse {
21+
pub(crate) active_calendars: i32,
22+
}

app/src/db.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ impl Db {
8181
});
8282
}
8383

84+
pub async fn list_calendars(&self) -> Vec<Calendar> {
85+
self.calendars.values().cloned().collect()
86+
}
87+
8488
pub async fn add_calendar(&mut self, mut calendar: Calendar) -> String {
8589
let id = calendar.id.clone();
8690
// We don't want to store all events in the DB, just the calendar metadata
@@ -90,12 +94,23 @@ impl Db {
9094
id
9195
}
9296

93-
pub async fn get_calendar(&self, id: &str) -> Option<Calendar> {
97+
pub async fn get_calendar(&mut self, id: &str) -> Option<Calendar> {
9498
let calendars = self.calendars.get(id);
9599
let Some(mut calendar) = calendars.cloned() else {
96100
return None;
97101
};
98102

103+
// Update last accessed time
104+
let now = SystemTime::now()
105+
.duration_since(UNIX_EPOCH)
106+
.unwrap_or_default()
107+
.as_secs();
108+
109+
if let Some(calendar_ref) = self.calendars.get_mut(id) {
110+
calendar_ref.last_accessed = Some(now);
111+
self.save_data_bg().await;
112+
}
113+
99114
// Check if we have cached events that are not expired
100115
let mut event_cache = EVENT_CACHE.lock().await;
101116
let cache = event_cache.get_mut(id);
@@ -287,6 +302,49 @@ impl Db {
287302
self.save_data_bg().await; // Auto-save
288303
Ok(())
289304
}
305+
306+
pub async fn cleanup_old_calendars(&mut self) -> Vec<String> {
307+
let now = SystemTime::now()
308+
.duration_since(UNIX_EPOCH)
309+
.unwrap_or_default()
310+
.as_secs();
311+
312+
// One week in seconds = 7 days * 24 hours * 60 minutes * 60 seconds
313+
let one_week_in_seconds = 7 * 24 * 60 * 60;
314+
let cutoff_time = now - one_week_in_seconds;
315+
316+
let mut removed_ids = Vec::new();
317+
318+
// Identify calendars to remove (older than one week)
319+
let to_remove: Vec<String> = self
320+
.calendars
321+
.iter()
322+
.filter(|(_, calendar)| {
323+
match calendar.last_accessed {
324+
Some(last_accessed) => last_accessed < cutoff_time,
325+
None => true, // Remove calendars with no access timestamp
326+
}
327+
})
328+
.map(|(id, _)| id.clone())
329+
.collect();
330+
331+
// Remove identified calendars
332+
for id in &to_remove {
333+
self.calendars.remove(id);
334+
335+
// Also clean up the event cache for this calendar
336+
let mut event_cache = EVENT_CACHE.lock().await;
337+
event_cache.remove(id);
338+
339+
removed_ids.push(id.clone());
340+
}
341+
342+
if !removed_ids.is_empty() {
343+
self.save_data_bg().await;
344+
}
345+
346+
removed_ids
347+
}
290348
}
291349

292350
// Database instance type

app/src/main.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use crate::api::routes;
22
use std::net::SocketAddr;
3+
use std::sync::Arc;
4+
use std::time::Duration;
5+
use tokio::sync::Mutex;
36
use tower_http::cors::{Any, CorsLayer};
47

58
mod api;
@@ -17,6 +20,9 @@ async fn main() -> std::io::Result<()> {
1720
// Create shared database instance
1821
let db_state = db::create_db_instance(db_path).await;
1922

23+
// Start background task to clean up old calendars
24+
start_calendar_cleanup_task(Arc::clone(&db_state));
25+
2026
let socket_address: SocketAddr = "0.0.0.0:8000".parse().unwrap();
2127
let listener = tokio::net::TcpListener::bind(socket_address).await?;
2228

@@ -38,11 +44,23 @@ async fn main() -> std::io::Result<()> {
3844

3945
println!("Calendar Curator backend starting on {}", socket_address);
4046
axum::serve(listener, app.into_make_service()).await
47+
}
48+
49+
fn start_calendar_cleanup_task(db_state: Arc<Mutex<db::Db>>) {
50+
tokio::spawn(async move {
51+
loop {
52+
tokio::time::sleep(Duration::from_secs(30 * 60 * 60)).await;
53+
54+
// Clean up calendars that haven't been accessed in a week
55+
let mut db = db_state.lock().await;
56+
let removed_ids = db.cleanup_old_calendars().await;
4157

42-
// let str = get_calendar(Url::parse("https://cloud.timeedit.net/lu/web/lth1/ri6X80g51560Y2QQ95Z59X0Y0Yy5002495967Q564f596Z53X04Y55545761924X5595951539X54444399XQ55X554X676349yZoXy1u6beZnQQ90Z.ics")?).await;
43-
// match str {
44-
// Ok(calendar) => println!("{}", calendar.to_string()),
45-
// Err(e) => eprintln!("Error fetching calendar: {}", e),
46-
// };
47-
// Ok(())
58+
if !removed_ids.is_empty() {
59+
println!(
60+
"Cleaned up {} calendars that haven't been accessed in a week",
61+
removed_ids.len()
62+
);
63+
}
64+
}
65+
});
4866
}

app/src/processing/calendar.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub struct Calendar {
1818
pub manually_blocked: HashSet<String>,
1919
/// List of manually allowlisted event IDs (immune from rule blocking).
2020
pub manually_allowlisted: HashSet<String>,
21+
pub last_accessed: Option<u64>,
2122
}
2223

2324
impl Calendar {
@@ -29,6 +30,7 @@ impl Calendar {
2930
url,
3031
manually_blocked: HashSet::new(),
3132
manually_allowlisted: HashSet::new(),
33+
last_accessed: None,
3234
}
3335
}
3436

@@ -64,10 +66,6 @@ impl Calendar {
6466
.collect()
6567
}
6668

67-
pub fn get_events(self) -> Vec<Event> {
68-
self.ical.events
69-
}
70-
7169
pub fn get_filtered_icalendar(&self) -> ICalendar {
7270
// To prevent cloning the events
7371
ICalendar {

web/app/layout.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
import type { Metadata } from "next";
2-
import { Geist, Geist_Mono } from "next/font/google";
32
import "./globals.css";
43

5-
const geistSans = Geist({
6-
variable: "--font-geist-sans",
7-
subsets: ["latin"],
8-
});
9-
10-
const geistMono = Geist_Mono({
11-
variable: "--font-geist-mono",
12-
subsets: ["latin"],
13-
});
14-
154
export const metadata: Metadata = {
165
title: "Calendar Curator",
176
description: "Curate your calendar events with custom rules",
@@ -24,11 +13,7 @@ export default function RootLayout({
2413
}>) {
2514
return (
2615
<html lang="en">
27-
<body
28-
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29-
>
30-
{children}
31-
</body>
16+
<body className="antialiased">{children}</body>
3217
</html>
3318
);
3419
}

web/app/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import { Home } from "@/components/home";
44
import React from "react";
5+
import { QueryClientProvider } from "@tanstack/react-query";
6+
import { QueryClient } from "@tanstack/query-core";
57

68
export default function Page() {
7-
return <Home />;
9+
return (
10+
<QueryClientProvider client={new QueryClient()}>
11+
<Home />
12+
</QueryClientProvider>
13+
);
814
}

web/components/blocked-events-panel.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@
33
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
44
import { Button } from "@/components/ui/button";
55
import { Badge } from "@/components/ui/badge";
6-
import {
7-
EyeIcon,
8-
EyeOffIcon, MapPinIcon,
9-
ShieldCheckIcon,
10-
ShieldXIcon,
11-
} from "lucide-react";
6+
import { EyeIcon, EyeOffIcon, MapPinIcon, ShieldCheckIcon, ShieldXIcon, } from "lucide-react";
127
import { CalendarEvent } from "@/lib/api";
138
import { getCalendarSettings } from "@/lib/settings";
149

@@ -129,7 +124,7 @@ export function BlockedEventsPanel({
129124

130125
{event.original.location && (
131126
<div className="text-xs text-muted-foreground">
132-
<MapPinIcon/>
127+
<MapPinIcon />
133128
{event.original.location}
134129
</div>
135130
)}

web/components/header.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { CalendarIcon } from "lucide-react";
2+
import { useEffect, useState } from "react";
3+
import { $api } from "@/lib/api";
4+
5+
export function Header() {
6+
const [recentCalendars, setActiveCalendars] = useState<number | null>(null);
7+
8+
let { data: stats } = $api.useQuery("get", "/stats", {
9+
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
10+
});
11+
12+
useEffect(() => {
13+
if (stats) {
14+
setActiveCalendars(stats.active_calendars);
15+
}
16+
}, [stats]);
17+
18+
if (recentCalendars === null) {
19+
return null;
20+
}
21+
22+
return (
23+
<>
24+
<div className="text-center space-y-2">
25+
<h1 className="text-4xl font-bold flex items-center justify-center gap-2 ">
26+
<div className="gap-2 flex items-center justify-center bg-gradient-to-r from-blue-600 via-indigo-600 to-purple-600 bg-clip-text text-transparent">
27+
<CalendarIcon className="h-8 w-8 text-blue-600" />
28+
Calendar Curator
29+
</div>
30+
</h1>
31+
{recentCalendars && (
32+
<p className="text-slate-600">
33+
Currently helping{" "}
34+
<b>
35+
{recentCalendars} {recentCalendars === 1 ? "person" : "people"}
36+
</b>{" "}
37+
manage their calendars
38+
</p>
39+
)}
40+
</div>
41+
<p className="absolute left-[90%] top-4 text-sm text-slate-500">
42+
Made with ❤️ by{" "}
43+
<a
44+
href="https://github.com/confusinguser/calendar-curator"
45+
className="text-blue-500 hover:underline"
46+
>
47+
Mostafa Kerim
48+
</a>
49+
</p>
50+
</>
51+
);
52+
}

0 commit comments

Comments
 (0)