Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for downloading screentime events as csv #237

Merged
merged 3 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 13 additions & 11 deletions frontend/src/components/features/integrations/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ export const integrations = [
// description: "MagicFlow is the productivity tracker that helps you focus on what matters most.",
// active: true,
// },
// {
// slug: "activityWatch",
// title: "ActivityWatch (Screen Time)",
// href: "https://activitywatch.net",
// imageUrl: "/images/integrations/activitywatch_icon.png",
// description:
// "ActivityWatch is an open source, privacy first app that automatically tracks how you spend time on your devices.",
// active: true,
// },
{
slug: "activityWatch",
title: "ActivityWatch (Screen Time)",
href: "https://activitywatch.net",
imageUrl: "/images/integrations/activitywatch_icon.png",
description:
"ActivityWatch is an open source, privacy first app that automatically tracks how you spend time on your devices.",
active: true,
},
// {
// slug: "spotify",
// title: "Spotify (Music)",
Expand Down Expand Up @@ -106,13 +106,15 @@ export const activityWatchSteps = [
{
id: 1,
step: "Open the server configurations",
image: "/images/integrations/activitywatch_guide_step1.png",
},
{
id: 2,
step: "Add Fusion as one of the allowed domains",
step: "Add Fusion urls as one of the allowed domains",
image: "/images/integrations/activitywatch_guide_step2.png",
},
{
id: 3,
step: "Quit & Restart ActivityWatch",
step: "Quit & Restart ActivityWatch then click 'Fetch Hosts'",
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export const IntegrationsContainer = () => {

const museContext = useContext(MuseContext);

const [activityWatchConnected, setActivityWatchConnected] = useState(false);

useEffect(() => {
// Check ActivityWatch connection status after component mounts
const isActivityWatchConnected =
typeof window !== "undefined" && Boolean(localStorage?.getItem("selectedActivityWatchHost"));
setActivityWatchConnected(isActivityWatchConnected);
}, []);

function handleIntegrationClick(integrationSlug: IntegrationSlug) {
switch (integrationSlug) {
case "fusion":
Expand Down Expand Up @@ -71,7 +80,7 @@ export const IntegrationsContainer = () => {
return Boolean(magicflowData?.magicflowToken);
case "activityWatch":
// check if activityWatch is connected
return false;
return activityWatchConnected;
case "muse":
// call function for muse integration
return Boolean(museContext?.museClient?.connectionStatus);
Expand Down Expand Up @@ -111,16 +120,11 @@ export const IntegrationsContainer = () => {
}
}, [neurosityLoading, user]);

// useEffect(() => {
// if (!museContext.museClient) {
// museContext.connectMuse();
// }
// }, [user]);
return (
<section>
<h1 className="text-4xl">Integrations and all connected apps</h1>
<p className="mb-10 mt-2 text-lg dark:text-slate-400">
Connect to applications and sensors for context on your life experiences{" "}
Connect to applications and sensors for context on when life events happen{" "}
</p>
<div className="flex flex-wrap gap-8">
{integrations.map((integration) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,73 @@
import * as Accordion from "@radix-ui/react-accordion";
import * as Dialog from "@radix-ui/react-dialog";
import { ChevronDown, X } from "lucide-react";
import { ChevronDown, Download, RefreshCw, X } from "lucide-react";
import Image from "next/image";
import { useSession } from "next-auth/react";
import { FC, useState } from "react";
import { AWClient } from "aw-client";
import { FC, useEffect, useState } from "react";
import { AWClient, IBucket } from "aw-client";

import { Button, Input } from "~/components/ui";
import { activityWatchSteps } from "../data";
import dayjs from "dayjs";
import { writeDataToStore } from "~/services/storage.service";
import { appInsights } from "~/utils/appInsights";

interface IActivityWatchModalProps {
isOpen: boolean;
onCloseModal: () => void;
}

export const ActivityWatchModal: FC<IActivityWatchModalProps> = ({ isOpen, onCloseModal }) => {
const { data: sessionData } = useSession();
const [hostname, setHostname] = useState("");
const user = useSession();
const [hostList, setHostList] = useState<{ [bucketId: string]: IBucket }>({});
const [selectedHost, setSelectedHost] = useState("");

const handleSubmit = () => {
// make the aw cient calls here
const [startDate, setStartDate] = useState(dayjs().subtract(1, "month"));
const [endDate, setEndDate] = useState(dayjs());

const fetchHosts = () => {
const awClient = new AWClient("fusionClient");
awClient.getBuckets().then((buckets) => {
console.log(buckets);
setHostList(buckets);
});
};

const fetchData = async (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
// make the aw cient calls here
const awClient = new AWClient("fusionClient");
const events = await awClient.getEvents(selectedHost, {
start: startDate.toDate(),
end: endDate.toDate(),
});
return events;
};

const handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => setHostname(evt.target.value);
useEffect(() => {
fetchHosts();
}, []);

// Save selectedHost to local storage whenever it changes
useEffect(() => {
if (selectedHost) {
localStorage.setItem("selectedActivityWatchHost", selectedHost);

appInsights.trackEvent({
name: "ActivityWatchHostSelected",
properties: {
userNpub: user?.data?.user?.email, // this is actually the npub, need to cast the session type so I can use the right selection
unixTimestamp: dayjs().unix(),
},
});
}
}, [selectedHost]);

// Load selectedHost from local storage on component mount
useEffect(() => {
const savedHost = localStorage.getItem("selectedActivityWatchHost");
if (savedHost && hostList[savedHost]) {
setSelectedHost(savedHost);
}
}, [hostList]);

return (
<Dialog.Root open={isOpen}>
Expand All @@ -37,10 +78,10 @@ export const ActivityWatchModal: FC<IActivityWatchModalProps> = ({ isOpen, onClo
/>
<Dialog.Content className="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-30 h-auto max-h-[85vh] w-[450px] max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-auto rounded-[6px] bg-white p-8 focus:outline-none dark:bg-slate-800 md:w-[600px]">
<Dialog.Title className="mb-1 w-10/12 font-body text-lg md:w-full md:text-2xl">
Connect ActivityWatch data
Connect ActivityWatch
</Dialog.Title>
<Dialog.Description className="mb-5 text-sm text-gray-700 dark:text-gray-300 md:text-base">
Enter your hostname to fetch existing data
Choose your ActivityWatch host to fetch your screentime data
</Dialog.Description>

<Dialog.Close
Expand All @@ -51,35 +92,126 @@ export const ActivityWatchModal: FC<IActivityWatchModalProps> = ({ isOpen, onClo
<span className="sr-only">Close</span>
</Dialog.Close>

<h4 className="font-body text-lg">Steps</h4>
<Accordion.Root type="single" collapsible className="divide-y dark:divide-slate-600">
{activityWatchSteps.map((step) => (
<Accordion.Item key={step.id} value={`step-${step.id}`}>
<Accordion.Trigger className="flex w-full items-center justify-between py-4 transition-all [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center">
<div className="mr-4 flex h-4 w-4 items-center justify-center rounded-full bg-secondary-500 p-3 font-semibold text-white">
{step.id}
</div>
<p className="text-left font-medium">{step.step}</p>
</div>

<ChevronDown className="hidden transition-transform duration-200 sm:block" />
</Accordion.Trigger>
<Accordion.Content>
{/* <Image src={step.image} alt="Magic Flow Home page" width={600} height={300} /> */}
</Accordion.Content>
</Accordion.Item>
))}
{/* TODO: Update activity watch steps and include it here */}
{/* we should actually fetch the buckets */}
</Accordion.Root>
{(!hostList || Object.keys(hostList).length === 0) && (
<div>
<h4 className="font-body text-lg">Steps</h4>
<Accordion.Root type="single" collapsible className="divide-y dark:divide-slate-600">
{activityWatchSteps.map((step) => (
<Accordion.Item key={step.id} value={`step-${step.id}`}>
<Accordion.Trigger className="flex w-full items-center justify-between py-4 transition-all [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center">
<div className="mr-4 flex h-4 w-4 items-center justify-center rounded-full bg-secondary-500 p-3 font-semibold text-white">
{step.id}
</div>
<p className="text-left font-medium">{step.step}</p>
</div>

<ChevronDown className="hidden transition-transform duration-200 sm:block" />
</Accordion.Trigger>
{step.image && (
<Accordion.Content>
<Image src={step.image} alt="Activity Watch Guide" width={600} height={300} />
</Accordion.Content>
)}
</Accordion.Item>
))}
</Accordion.Root>
</div>
)}

<div className="mt-8 flex w-full flex-wrap items-center gap-4 py-6 md:flex-nowrap">
{/* <Input value={hostname} placeholder="ActivityWatch Hostname" onChange={handleChange} fullWidth /> */}
<Button type="submit" onClick={handleSubmit}>
{"Fetch Hosts"}
<Button type="submit" onClick={fetchHosts} leftIcon={<RefreshCw className="h-4 w-4" />}>
Fetch Hosts
</Button>

<div className="w-full">
<label htmlFor="hostSelect" className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
Select ActivityWatch Host
</label>
<select
id="hostSelect"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
value={selectedHost}
onChange={(e) => setSelectedHost(e.target.value)}
>
<option value="">Choose a host</option>
{Object.values(hostList).map((host, index) => (
<option key={index} value={host.id}>
{host.id}
</option>
))}
</select>
</div>
</div>

{hostList && Object.keys(hostList).length > 0 && (
<div>
<h4 className="font-body text-lg">Fetch Screentime Data</h4>

<div className="mt-4 space-y-4">
<div>
<label htmlFor="startDate" className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
Start Date
</label>
<Input
type="datetime-local"
id="startDate"
defaultValue={startDate.format("YYYY-MM-DDTHH:mm")}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
onChange={(e) => setStartDate(dayjs(e.target.value))}
/>
</div>
<div>
<label htmlFor="endDate" className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
End Date
</label>
<Input
type="datetime-local"
id="endDate"
defaultValue={endDate.format("YYYY-MM-DDTHH:mm")}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
onChange={(e) => setEndDate(dayjs(e.target.value))}
/>
</div>
</div>

<div className="mt-4">
<Button
intent="primary"
className="w-full"
onClick={async () => {
// Add logic to fetch data here
const events = await fetchData(dayjs(startDate), dayjs(endDate));

// parse the list into a csv dataset
const data = events.map((event) => ({
unixTimestamp: dayjs(event.timestamp).unix(),
duration_secs: event.duration,
app: event.data?.app,
title: event.data?.title,
}));

console.log("Length of events", events.length);
console.log("Length of data", data.length);

writeDataToStore("activitywatch", data, endDate.unix().toString(), "download");

appInsights.trackEvent({
name: "ActivityWatchDataDownloaded",
properties: {
userNpub: user?.data?.user?.email, // this is actually the npub, need to cast the session type so I can use the right selection
unixTimestamp: dayjs().unix(),
},
});
}}
leftIcon={<Download className="mr-2 h-4 w-4" />}
disabled={!selectedHost}
>
Get Screentime Events
</Button>
</div>
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const IntegrationsPage: NextPage = () => {
<DashboardLayout>
<Meta
meta={{
title: "Integrations | Fusion - Personal Insights from your daily habits and actions",
title: "Integrations | NeuroFusion",
}}
/>
<IntegrationsContainer />
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/services/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export function convertToCSV(arr: any[]) {

return array
.map((it) => {
return Object.values(it).toString();
return Object.values(it)
.map((value) => {
if (typeof value === "string" && value.includes(",")) {
// Escape commas and wrap in quotes
return `"${value.replace(/"/g, '""')}"`;
}
return value;
})
.join(",");
})
.join("\n");
}
Expand All @@ -28,7 +36,7 @@ export async function writeDataToStore(dataName: string, data: any, fileTimestam
const content = convertToCSV(data); // convert to csv format

const hiddenElement = document.createElement("a");
hiddenElement.href = `data:text/csv;charset=utf-8,${encodeURI(content)}`;
hiddenElement.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`;
hiddenElement.target = "_blank";
hiddenElement.download = fileName;
hiddenElement.click();
Expand Down
Loading