diff --git a/src/background.index.ts b/src/background.index.ts index 9314127..351c5da 100644 --- a/src/background.index.ts +++ b/src/background.index.ts @@ -1,5 +1,6 @@ import { getTabInfo } from './background/browser-api/tabs'; import { handleStateChange } from './background/controller'; +import { cleanUpLogIndexedDBTable } from './background/services/logs'; import { handleActiveTabStateChange, handleAlarm, @@ -7,7 +8,7 @@ import { handleTabUpdate, handleWindowFocusChange, } from './background/services/state-service'; -import { sendWebActivityAutomatically } from './background/services/stats'; +import { cleanUpStatsIndexedDBTable, sendWebActivityAutomatically } from './background/services/stats'; import { logMessage } from './background/tables/logs'; import { Tab } from './shared/browser-api.types'; import { DebugTab } from './shared/db/types'; @@ -19,6 +20,7 @@ interface Service { name: string; intervalInMinutes: number; handler: () => Promise; + delayInMinutes?: number; } const devToolsPorts: { [tabId: number]: Port } = {}; @@ -30,6 +32,13 @@ const ASYNC_POLL_INTERVAL_MINUTES = 1; const ASYNC_STATS_INTERVAL_ALARM_NAME = 'send-stats'; const ASYNC_STATS_INTERVAL_MINUTES = 1; +const ASYNC_CLEAN_UP_LOGS_ALARM_NAME = 'cleanup-logs'; +const ASYNC_CLEAN_UP_LOGS_MINUTES = 1440; // Daily once +const DEFAULT_LOG_CUTOFF_DAYS = 0; // Daily + +const ASYNC_CLEAN_UP_STATS_ALARM_NAME = 'cleanup-stats'; +const ASYNC_CLEAN_UP_STATS_MINUTES = 1440; // Daily once + function findDebuggingTabIndexFromId(tabIdOrUrl: number | string | undefined) { if (tabIdOrUrl !== undefined) { for (let i = 0; i < debuggingTabs.length; i++) { @@ -78,6 +87,14 @@ const asyncPollAlarmHandler = async (): Promise => { const sendStatsAlarmHandler = async (): Promise => await sendWebActivityAutomatically(); +const cleanUpLogsAlarmHandler = async (): Promise => { + await cleanUpLogIndexedDBTable(DEFAULT_LOG_CUTOFF_DAYS); +}; + +const cleanUpStatsAlarmHandler = async (): Promise => { + await cleanUpStatsIndexedDBTable(); +}; + const ChromeServiceDefinition: Array = [ { handler: asyncPollAlarmHandler, @@ -89,11 +106,25 @@ const ChromeServiceDefinition: Array = [ intervalInMinutes: ASYNC_STATS_INTERVAL_MINUTES, name: ASYNC_STATS_INTERVAL_ALARM_NAME, }, + { + handler: cleanUpLogsAlarmHandler, + intervalInMinutes: ASYNC_CLEAN_UP_LOGS_MINUTES, + name: ASYNC_CLEAN_UP_LOGS_ALARM_NAME, + }, + { + delayInMinutes: 0.1, + handler: cleanUpStatsAlarmHandler, + intervalInMinutes: ASYNC_CLEAN_UP_STATS_MINUTES, + name: ASYNC_CLEAN_UP_STATS_ALARM_NAME, + }, ]; ChromeServiceDefinition.forEach((service) => { chrome.alarms.create(service.name, { periodInMinutes: service.intervalInMinutes, + ...(service.delayInMinutes !== undefined && service.delayInMinutes !== null + ? { delayInMinutes: service.delayInMinutes } + : {}) }); }); diff --git a/src/background/controller/index.ts b/src/background/controller/index.ts index 27d57c5..562006d 100644 --- a/src/background/controller/index.ts +++ b/src/background/controller/index.ts @@ -8,6 +8,7 @@ import { import { getSettings } from '../../shared/preferences'; import { getIsoDate, getMinutesInMs } from '../../shared/utils/dates-helper'; import { isInvalidUrl, isDomainAllowedByUser } from '../../shared/utils/url'; +import { logMessage } from '../tables/logs'; import { setActiveTabRecord } from '../tables/state'; import { ActiveTimelineRecordDao, createNewActiveRecord } from './active'; import { updateTimeOnBadge } from './badge'; @@ -152,6 +153,10 @@ async function commitTabActivity(currentTimelineRecord: TimelineRecord | null, p const currentIsoDate = getIsoDate(new Date()); + const { hostname } = currentTimelineRecord; + const message = `Visited ${hostname} on ${currentIsoDate}`; + await logMessage(message); + await saveTimelineRecord(currentTimelineRecord, currentIsoDate); // Edge case: If update happens after midnight, we need to update the diff --git a/src/background/services/logs.ts b/src/background/services/logs.ts new file mode 100644 index 0000000..3162c2d --- /dev/null +++ b/src/background/services/logs.ts @@ -0,0 +1,72 @@ +import { connect, TimeTrackerStoreTables } from '../../shared/db/idb'; + +const getIdsToDelete = async (cutOffDate: Date) => { + console.log(`Cut Off Date: ${cutOffDate.getTime()}`); + const db = await connect(); + const logs = await db.getAll( + TimeTrackerStoreTables.Logs, + ); + const idsToDelete: number[] = []; + + for (const log of logs) { + const recordDate = new Date(log.timestamp).getTime(); + if (recordDate < cutOffDate.getTime()) { + const id: number = log.id as number; + // Should be deleted + idsToDelete.push(id); + } + } + + return idsToDelete; +}; + +const deleteRecordsById = async (idsToDelete: number[]) => { + try { + + const db = await connect(); + const transaction = db.transaction( + TimeTrackerStoreTables.Logs, + 'readwrite', + ); + const store = transaction.objectStore(TimeTrackerStoreTables.Logs); + + for (const id of idsToDelete) { + // Using any as delete function expects string but we store id as number + // this throws lint error + const delId: any = id; + await store.delete(delId); + } + + await transaction.done; + console.log('Logs deleted successfully.'); + return 'Logs deleted successfully.'; + } catch (error) { + console.error('Error deleting IDB records:', error); + return 'Failed to delete records.'; + } +}; + +const cleanUpLogIndexedDBTable = async (cutOff: number) => { + console.log(`handling log table cleanup with cutoff of ${cutOff}`) + if (process.env.NODE_ENV === 'production') { + return; + } + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - cutOff); + + const idsToDelete = await getIdsToDelete(cutoffDate); + + console.log( + `Records to Delete: ${JSON.stringify(idsToDelete, null, 2)}`, + ); + + if (idsToDelete.length > 0) { + return await deleteRecordsById(idsToDelete); + } + + return `No logs data available for keeping beyond the ${cutOff} days period.` +}; + + +export { cleanUpLogIndexedDBTable } diff --git a/src/background/services/stats.ts b/src/background/services/stats.ts index 988ff48..0cb5e0a 100644 --- a/src/background/services/stats.ts +++ b/src/background/services/stats.ts @@ -178,4 +178,43 @@ const sendWebActivityAutomatically = async (): Promise => { }); }; -export { sendWebActivityAutomatically }; +const deleteRecordsById = async (idsToDelete: string[]) => { + try { + + const db = await connect(); + const transaction = db.transaction( + TimeTrackerStoreTables.State, + 'readwrite', + ); + const store = transaction.objectStore(TimeTrackerStoreTables.State); + + for (const id of idsToDelete) { + await store.delete(id); + } + + await transaction.done; + console.log('Stats deleted successfully.'); + return 'Stats deleted successfully.'; + } catch (error) { + console.error('Error deleting IDB records:', error); + return 'Failed to delete records.'; + } +}; + +const cleanUpStatsIndexedDBTable = async () => { + console.log(`handling stats table cleanup`) + + const keysToDelete = ['active-tab', 'app-state', 'overall-state']; + + console.log( + `Records to Delete: ${JSON.stringify(keysToDelete, null, 2)}`, + ); + + if (keysToDelete.length > 0) { + return await deleteRecordsById(keysToDelete); + } + + return `Stats table cleared and ready to be repopulated.` +}; + +export { sendWebActivityAutomatically, cleanUpStatsIndexedDBTable }; diff --git a/src/background/tables/logs.ts b/src/background/tables/logs.ts index 8155185..90e2b68 100644 --- a/src/background/tables/logs.ts +++ b/src/background/tables/logs.ts @@ -1,4 +1,5 @@ import { connect, TimeTrackerStoreTables } from '../../shared/db/idb'; +import { LogMessage } from '../../shared/db/types'; export async function logMessage(message: string) { if (process.env.NODE_ENV === 'production') { @@ -8,3 +9,12 @@ export async function logMessage(message: string) { const db = await connect(); await db.add(TimeTrackerStoreTables.Logs, { message, timestamp: Date.now() }); } + +export async function getLogs(): Promise { + if (process.env.NODE_ENV === 'production') { + return []; + } + + const db = await connect(); + return await db.getAll(TimeTrackerStoreTables.Logs); +} \ No newline at end of file diff --git a/src/content/sleep-counter-measures.ts b/src/content/sleep-counter-measures.ts index d8b2453..7b09d70 100644 --- a/src/content/sleep-counter-measures.ts +++ b/src/content/sleep-counter-measures.ts @@ -8,6 +8,7 @@ import { getMinutesInMs } from '../shared/utils/dates-helper'; import { ignore } from '../shared/utils/errors'; let messagePollingId = 0; +let backgroundPort: chrome.runtime.Port | null = null; function tryWakeUpBackground() { chrome.runtime.sendMessage({ type: WAKE_UP_BACKGROUND }, (response) => { @@ -35,17 +36,37 @@ function tryWakeUpBackground() { } function connectToExtension() { - chrome.runtime.connect().onDisconnect.addListener(() => { + if (backgroundPort) { + return; // Already connected + } + + backgroundPort = chrome.runtime.connect({ name: "codealike-chrome" }); // Use a name for the port + + backgroundPort.onDisconnect.addListener(() => { + console.log("Disconnected from background script."); + backgroundPort = null; // Reset the port + try { - throwRuntimeLastError(); + throwRuntimeLastError(); // Your error logging function } catch (error) { ignore( isExtensionContextInvalidatedError, isCouldNotEstablishConnectionError + // You might want to add a check for the back/forward cache error here )(error); } - setTimeout(() => connectToExtension(), getMinutesInMs(1)); + + // Fallback reconnection after a delay + setTimeout(connectToExtension, getMinutesInMs(1)); }); + + // Optionally, send an initial message upon successful connection + backgroundPort.onMessage.addListener((message) => { + console.log("Received message from background:", message); + // Handle messages + }); + + console.log("Connected to background script."); } export const runManifestV3SleepCounterMeasures = () => { @@ -60,3 +81,12 @@ export const runManifestV3SleepCounterMeasures = () => { } }); }; + +// Listen for the 'pageshow' event to attempt immediate reconnection +// when the page is restored from the back/forward cache +window.addEventListener('pageshow', (event) => { + if (event.persisted && !backgroundPort) { + console.log("Page restored from cache, attempting immediate reconnection."); + connectToExtension(); + } +}); \ No newline at end of file diff --git a/src/popup/components/LogViewer/LogViewer.tsx b/src/popup/components/LogViewer/LogViewer.tsx new file mode 100644 index 0000000..bd4e383 --- /dev/null +++ b/src/popup/components/LogViewer/LogViewer.tsx @@ -0,0 +1,186 @@ +import * as React from "react" +import { Panel, PanelBody, PanelHeader } from "../../../blocks/Panel" +import { usePopupContext } from "../../hooks/PopupContext" +import { LogMessage } from "../../../shared/db/types"; +import { formatEpoch } from "../../../shared/utils/dates-helper"; +import { Input } from "../../../blocks/Input"; +import { LogFilterTypes } from "./type"; +import { Button, ButtonType } from "../../../blocks/Button"; +import { handleLogTableCleanUp } from "../../../shared/db/helper"; + +export const LogViewer: React.FC = () => { + const { logs } = usePopupContext(); + const [state, setState] = React.useState<{ + cutOffDate: number | string + filteredLogs: LogMessage[] + filters: LogFilterTypes + isChecked: boolean, + status: boolean, + statusText: string, + }>({ + cutOffDate: '', + filteredLogs: [], + filters: LogFilterTypes.WEBSITE, + isChecked: true, + status: false, + statusText: '', + }) + + + const getFilteredLogs = React.useCallback((appliedFilter?: LogFilterTypes) => { + let filtered = logs?.slice(-100).reverse() + + if (appliedFilter === LogFilterTypes.WEBSITE) { + filtered = filtered?.filter(log => log.message.includes('Visited')) + } + + if (appliedFilter === LogFilterTypes.OTHER) { + filtered = filtered?.filter(log => !log.message.includes('Visited')) + } + + return filtered; + }, [logs]); + + React.useEffect(() => { + (async function () { + const updatedLogs = getFilteredLogs(state.filters) + + setState((prev) => ({ + ...prev, + filteredLogs: updatedLogs as LogMessage[] + })) + + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggleFilters = React.useCallback((filter: LogFilterTypes) => { + const updatedLogs = getFilteredLogs(filter); + + setState((prev) => ({ + ...prev, + filteredLogs: updatedLogs as LogMessage[], + filters: filter, + })) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleCutOffDate = React.useCallback( + (e: React.ChangeEvent) => { + setState((prev) => ({ + ...prev, + cutOffDate: Number(e.target.value), + })); + }, + [], + ); + + const handleClearLogData = React.useCallback(() => { + const days = Number(state.cutOffDate); + if (days < 0) { + setState((prev) => ({ + ...prev, + status: true, + statusText: 'Cut Off Date cannot be lower than 0' + })); + return; + } + + // Call the cleanup function + handleLogTableCleanUp(days) + .then((res) => { + // Update state + setState((prev) => ({ + ...prev, + status: true, + statusText: res as string + })); + }) + + }, [state]); + + const { filteredLogs, filters, cutOffDate, status, statusText } = state; + return ( + + Log Viewer + +
+
+ toggleFilters(LogFilterTypes.ALL)} + /> + +
+ +
+ toggleFilters(LogFilterTypes.WEBSITE)} + /> + +
+ +
+ toggleFilters(LogFilterTypes.OTHER)} + /> + +
+
+ +
+
+
+ Time +
+
+ Message +
+
+ { + filteredLogs.map((log) => ( +
+
+ {formatEpoch(log.timestamp)} +
+
+ {log.message} +
+
+ )) + } +
+ +
+ + +
+

After clicking the Clear button, only the log data from your indexed db will be cleared.

+
+ {status + && (

{statusText}

) + } +
+
+
+ ) +} \ No newline at end of file diff --git a/src/popup/components/LogViewer/type.ts b/src/popup/components/LogViewer/type.ts new file mode 100644 index 0000000..851b97d --- /dev/null +++ b/src/popup/components/LogViewer/type.ts @@ -0,0 +1,5 @@ +export enum LogFilterTypes { + ALL = 'all', + WEBSITE = 'website', + OTHER = 'other' +} diff --git a/src/popup/hooks/PopupContext.tsx b/src/popup/hooks/PopupContext.tsx index a9b2b96..7c3d712 100644 --- a/src/popup/hooks/PopupContext.tsx +++ b/src/popup/hooks/PopupContext.tsx @@ -1,6 +1,8 @@ import { Preferences } from '../../shared/db/types'; import { DEFAULT_PREFERENCES } from '../../shared/preferences'; import { useActiveTabHostname } from './useActiveTab'; +import { useLogs } from './useLogs'; +import { LogMessage } from "../../shared/db/types"; import { useSettings } from './useSettings'; import { TimeStore, useTimeStore } from './useTimeStore'; import * as React from 'react'; @@ -8,12 +10,14 @@ import * as React from 'react'; export type PopupContextType = { store: TimeStore; activeHostname: string; + logs: LogMessage[] | [] | undefined settings: Preferences; updateSettings: (updated: Partial) => void; }; const DEFAULT_CONTEXT: PopupContextType = { activeHostname: '', + logs: [], settings: DEFAULT_PREFERENCES, store: {}, updateSettings: () => 0, @@ -28,6 +32,7 @@ export const PopupContextProvider: React.FC = ({ children }) => { const store = useTimeStore(); const host = useActiveTabHostname(); const [settings, updateSettings] = useSettings(); + const [logs] = useLogs(); const filterDomainsFromStore = React.useCallback( (store: Record) => { @@ -67,9 +72,10 @@ export const PopupContextProvider: React.FC = ({ children }) => { {children} diff --git a/src/popup/hooks/useLogs.ts b/src/popup/hooks/useLogs.ts new file mode 100644 index 0000000..29b42eb --- /dev/null +++ b/src/popup/hooks/useLogs.ts @@ -0,0 +1,16 @@ +import * as React from "react"; +import { getLogs } from "../../background/tables/logs"; +import { LogMessage } from "../../shared/db/types"; + +export const useLogs = () => { + const [logs, setLogs] = React.useState(); + + React.useEffect(() => { + (async function (){ + const dbLogs = await getLogs() + setLogs(dbLogs); + })(); + }, []); + + return [logs, setLogs] as const; +} \ No newline at end of file diff --git a/src/popup/pages/PreferencesPage.tsx b/src/popup/pages/PreferencesPage.tsx index 4633116..22558af 100644 --- a/src/popup/pages/PreferencesPage.tsx +++ b/src/popup/pages/PreferencesPage.tsx @@ -8,9 +8,20 @@ import { Input } from './../../blocks/Input'; import {IgnoredDomainSetting} from '../components/IgnoredDomainsSetting/IgnoredDomainSetting'; import { WhitelistDomainSetting } from '../components/WhitelistDomainsSetting/WhitelistDomainSetting'; import {UserTokenSetting} from "../components/UserTokenSetting/UserTokenSetting"; +import { LogViewer } from '../components/LogViewer/LogViewer'; export const PreferencesPage: FC = () => { const [isWhitelistShown, hideWhitelist] = React.useState(true); + const [currentEnv, setCurrentEnv] = React.useState(); + + React.useEffect(() => { + (async function () { + const ENV = process.env.NODE_ENV; + setCurrentEnv(ENV) + + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setCurrentEnv]); const toggle = React.useCallback(() => { hideWhitelist((prev) => !prev); @@ -45,6 +56,7 @@ export const PreferencesPage: FC = () => { {(isWhitelistShown ? : )} + {currentEnv === 'development' && } ); }; diff --git a/src/shared/db/helper.ts b/src/shared/db/helper.ts new file mode 100644 index 0000000..7fca7e0 --- /dev/null +++ b/src/shared/db/helper.ts @@ -0,0 +1,5 @@ +import { cleanUpLogIndexedDBTable } from '../../background/services/logs'; + +export const handleLogTableCleanUp = async (days: number) => { + return await cleanUpLogIndexedDBTable(days); +}; diff --git a/src/shared/db/types.ts b/src/shared/db/types.ts index e31518c..ee52dfb 100644 --- a/src/shared/db/types.ts +++ b/src/shared/db/types.ts @@ -51,6 +51,7 @@ export interface DebugTab { export interface LogMessage { message: string; timestamp: number; + id?: number; } export interface Preferences { diff --git a/src/shared/utils/dates-helper.ts b/src/shared/utils/dates-helper.ts index 72759ad..07185cd 100644 --- a/src/shared/utils/dates-helper.ts +++ b/src/shared/utils/dates-helper.ts @@ -81,3 +81,22 @@ export const presentHoursOrMinutesFromMinutes = (minutes: number) => { return `${hoursRounded - 0.5}h`; }; + +export const formatEpoch = (timestamp: number) => { + // Check if timestamp is in seconds and convert to milliseconds if needed + const isInSeconds = timestamp < 1e12; + const date = new Date(isInSeconds ? timestamp * 1000 : timestamp); + + // Format: YYYY-MM-DD HH:mm:ss + const formatted = date.toLocaleString('en-US', { + day: '2-digit', + hour: '2-digit', + hour12: true, + minute: '2-digit', + month: '2-digit', + second: '2-digit', + year: 'numeric', + }); + + return formatted; +}; \ No newline at end of file