Skip to content

Commit

Permalink
feat: allow people to connect to activity watch and download screen t…
Browse files Browse the repository at this point in the history
…ime events as csv
  • Loading branch information
oreHGA committed Aug 26, 2024
1 parent 1c013cd commit cfd45dd
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 55 deletions.
18 changes: 9 additions & 9 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
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,63 @@
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";

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

export const ActivityWatchModal: FC<IActivityWatchModalProps> = ({ isOpen, onCloseModal }) => {
const { data: sessionData } = useSession();
const [hostname, setHostname] = useState("");
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 handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => setHostname(evt.target.value);
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;
};

useEffect(() => {
fetchHosts();
}, []);

// Save selectedHost to local storage whenever it changes
useEffect(() => {
if (selectedHost) {
localStorage.setItem("selectedActivityWatchHost", selectedHost);
}
}, [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 +68,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,34 +82,117 @@ 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 && (
<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>
<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>
</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>

{hostList && (
<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>

<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");
}}
leftIcon={<Download className="mr-2 h-4 w-4" />}
disabled={!selectedHost}
>
Get Screentime Events
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
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

0 comments on commit cfd45dd

Please sign in to comment.