Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a099056
add basic custom report
OrangeII Jul 9, 2025
3bd5508
refactor chart code
OrangeII Jul 13, 2025
ccdf755
add grouping function
OrangeII Jul 13, 2025
1659d90
improve grouping logic
OrangeII Jul 13, 2025
1dd8419
implementa grouping logic to chart creation
OrangeII Jul 13, 2025
fd78194
add x and y axis config
OrangeII Jul 19, 2025
1e4d0b0
try different chart types
OrangeII Jul 19, 2025
ed9b2eb
add x and y values filtering
OrangeII Jul 19, 2025
613fa2a
add chart aesthetics configuration
OrangeII Jul 19, 2025
d54a8c2
improve chart style
OrangeII Jul 19, 2025
6295e5a
separate configuration for different chart types
OrangeII Jul 19, 2025
8226bc0
refactor chart types to their own components
OrangeII Jul 19, 2025
c7ba893
style charts
OrangeII Jul 19, 2025
aaa6eac
add gradient to line chart
OrangeII Jul 19, 2025
83f6b32
style charts
OrangeII Jul 19, 2025
a4cc8d3
style charts
OrangeII Jul 19, 2025
e9a3374
style charts
OrangeII Jul 19, 2025
71559b4
read chart data from backend
OrangeII Jul 20, 2025
7151b71
fix this_week date filter
OrangeII Jul 20, 2025
a7194f8
add group key aesthetics
OrangeII Jul 20, 2025
69d0708
add perio type aesthetics
OrangeII Jul 20, 2025
cc06e56
add chart type aew
OrangeII Jul 20, 2025
3178775
add custom select
OrangeII Jul 20, 2025
4fe6b3e
improve style
OrangeII Jul 20, 2025
1936562
add chart font size
OrangeII Jul 20, 2025
184f3e4
improve style
OrangeII Jul 20, 2025
f142ec1
remove x axis selection
OrangeII Jul 20, 2025
295a447
improve style
OrangeII Jul 20, 2025
7437c3d
use table instead of flex for chart configuration
OrangeII Jul 20, 2025
54b4087
refactor and fix sigle selection
OrangeII Jul 20, 2025
4b12c85
improve style
OrangeII Jul 20, 2025
a6c5ebe
fix start and end dates visualization
OrangeII Jul 20, 2025
efdf17c
improve style
OrangeII Jul 20, 2025
0a4cd5a
add app button, add actions in Chart edit page
OrangeII Jul 20, 2025
82b8f29
improve style
OrangeII Jul 20, 2025
4e3e7dd
add chart config crud logic
OrangeII Jul 23, 2025
8246493
add charts page
OrangeII Jul 23, 2025
32e2a52
fix chart save
OrangeII Jul 23, 2025
158a6d4
improve breadcrumbs style
OrangeII Jul 23, 2025
cb4d725
refactor AppChart
OrangeII Jul 23, 2025
5550039
add placeholder to create new chart
OrangeII Jul 23, 2025
a30598b
improve style
OrangeII Jul 23, 2025
6b6f223
improve style
OrangeII Jul 23, 2025
7d657b8
add chart delete functionality
OrangeII Jul 23, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
set check_function_bodies = off;

CREATE OR REPLACE FUNCTION public.refresh_time_entry_report()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY public.time_entry_report;
RETURN NULL;
END;
$function$
;

create materialized view "public"."time_entry_report" as SELECT time_entries.user_id,
time_entries.id AS time_entry_id,
time_entries.notes AS time_entry_notes,
time_entries.task_id,
time_entries.start_time,
time_entries.end_time,
tasks.name AS task_name,
tags.id AS tag_id,
tags.name AS tag_name,
tags.hex_color AS tag_color,
tags.dot_text AS tag_dot_text,
((EXTRACT(epoch FROM (time_entries.end_time - time_entries.start_time)) * (1000)::numeric))::bigint AS duration,
(EXTRACT(dow FROM time_entries.start_time))::integer AS weekday,
(EXTRACT(month FROM time_entries.start_time))::integer AS month,
(EXTRACT(year FROM time_entries.start_time))::integer AS year,
1 AS count
FROM (((time_entries
LEFT JOIN tasks ON ((time_entries.task_id = tasks.id)))
LEFT JOIN tasks_tags ON ((tasks_tags.task_id = tasks.id)))
LEFT JOIN tags ON ((tasks_tags.tag_id = tags.id)))
WHERE (time_entries.end_time IS NOT NULL);


CREATE INDEX idx_time_entry_report_start_time ON public.time_entry_report USING btree (start_time);

CREATE INDEX idx_time_entry_report_tag_id ON public.time_entry_report USING btree (tag_id);

CREATE INDEX idx_time_entry_report_task_id ON public.time_entry_report USING btree (task_id);

CREATE TRIGGER refresh_time_entry_report_trigger_tags AFTER INSERT OR DELETE OR UPDATE ON public.tags FOR EACH STATEMENT EXECUTE FUNCTION refresh_time_entry_report();

CREATE TRIGGER refresh_time_entry_report_trigger_tasks AFTER INSERT OR DELETE OR UPDATE ON public.tasks FOR EACH STATEMENT EXECUTE FUNCTION refresh_time_entry_report();

CREATE TRIGGER refresh_time_entry_report_trigger AFTER INSERT OR DELETE OR UPDATE ON public.time_entries FOR EACH STATEMENT EXECUTE FUNCTION refresh_time_entry_report();


Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- add a unique index so view can be updated concurrently
CREATE UNIQUE INDEX IF NOT EXISTS idx_time_entry_report_time_entry_id_tag_id ON public.time_entry_report USING btree (time_entry_id, tag_id);

-- Create a private schema if it doesn't exist
CREATE SCHEMA IF NOT EXISTS private;

-- Move the materialized view to the private schema
ALTER MATERIALIZED VIEW public.time_entry_report SET SCHEMA private;

-- Create a public view with RLS
CREATE OR REPLACE VIEW public.time_entry_report
WITH (security_invoker=on) AS
SELECT * FROM private.time_entry_report
WHERE (select auth.uid()) = user_id;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE OR REPLACE FUNCTION public.refresh_time_entry_report()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY private.time_entry_report;
RETURN NULL;
END;
$function$
;

-- move the function to the private schema
ALTER FUNCTION public.refresh_time_entry_report SET SCHEMA private;
7 changes: 7 additions & 0 deletions frontend/simple-tracker/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import AppNavigation from "./components/AppNavigation.vue";
import { useStyleStore } from "./stores/style.ts";
import { useVisibility } from "./common/useVisibility.ts";
import { useRegisterSW } from "virtual:pwa-register/vue";
import { Chart as ChartJS, registerables } from "chart.js";

const intervalMS = 60 * 60 * 1000; // 1 hour
useRegisterSW({
Expand Down Expand Up @@ -106,6 +107,12 @@ const { isMobile, isDesktop } = useBreakpoints();
let authStateChangeSub: Subscription | null = null;

onMounted(async () => {
ChartJS.register(...registerables);
ChartJS.defaults.font.size = styleStore.getFontSizePx();
ChartJS.defaults.color = styleStore.getTextColor();
ChartJS.defaults.backgroundColor = styleStore.getPrimaryColor();
ChartJS.defaults.borderColor = "black";

const { data } = await supabase.auth.getSession();
userStore.user = data?.session?.user || null;

Expand Down
108 changes: 108 additions & 0 deletions frontend/simple-tracker/src/common/charts/charts.supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { supabase } from "../../main";
import type { ChartConfig, DataPoint } from "./charts.types";

const DATA_TABLE_NAME = "time_entry_report";

export interface ChartConfigRecord {
id?: string;
user_id?: string;
created_at?: string;
chart_config: ChartConfig;
}

export async function fetchRawData(
startPeriod?: Date,
endPeriod?: Date
): Promise<DataPoint[]> {
try {
const { data, error } = await supabase
.from(DATA_TABLE_NAME)
.select("*")
.gte("start_time", startPeriod?.toISOString() || "1970-01-01T00:00:00Z")
.lt("end_time", endPeriod?.toISOString() || "9999-12-31T23:59:59Z")
.order("start_time", { ascending: true });
if (error) throw error;
if (!data) {
console.warn("No data found in fetchRawData");
return [];
}

data.forEach((item) => {
item.start_time = new Date(item.start_time).getTime();
item.end_time = new Date(item.end_time).getTime();
});

return data as DataPoint[];
} catch (error) {
console.error("Error in fetchRawData:", error);
throw error;
}
}

export async function fetchChartConfigs(): Promise<ChartConfigRecord[]> {
try {
const { data, error } = await supabase
.from("charts")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as ChartConfigRecord[];
} catch (error) {
console.error("Error in fetchChartConfigs:", error);
throw error;
}
}

/**
* Saves a chart configuration to the database. and returns the saved record.
* If the chart configuration already exists, it will be updated.
* @param chartConfig the chart configuration to save
* @returns the saved chart configuration record
*/
export async function saveChartConfig(
chartConfig: ChartConfigRecord
): Promise<ChartConfigRecord> {
try {
// If the chartConfig does not have an id, it means it's a new chart.
// I clear everything except the chart_config field so that a new record will use default values.
if (!chartConfig.id) {
chartConfig = { chart_config: chartConfig.chart_config };
}

console.log("Saving chart config:", chartConfig);
const { data, error } = await supabase
.from("charts")
.upsert(chartConfig, { onConflict: "id" })
.select()
.single();
if (error) throw error;
return data as ChartConfigRecord;
} catch (error) {
console.error("Error in saveChartConfig:", error);
throw error;
}
}

/**
* Deletes a chart configuration from the database.
* @param id the id of the chart configuration to delete
* @returns true if the chart configuration was deleted successfully, false otherwise
*/
export async function deleteChartConfig(id: string): Promise<boolean> {
try {
const { data, error } = await supabase
.from("charts")
.delete()
.eq("id", id)
.select();
if (error) throw error;
if (!data || data.length === 0) {
console.warn(`No chart config found to delete with id: ${id}`);
return false;
}
return true;
} catch (error) {
console.error("Error in deleteChartConfig:", error);
throw error;
}
}
130 changes: 130 additions & 0 deletions frontend/simple-tracker/src/common/charts/charts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
type ChartConfig,
type DataPoint,
type ChartData,
type DataPointGroup,
type GroupKeys,
DataPointValue,
GroupValueSettersConfig,
DataPointValueAesthetics,
GroupKeysAesthetics,
} from "./charts.types";
import { getDateIntevealFromPeriodType } from "./charts.utils";

function filterData(config: ChartConfig, rawData: DataPoint[]): DataPoint[] {
const { start, end } = getDateIntevealFromPeriodType(config.periodType);
return rawData.filter(
(item) =>
new Date(item.start_time) >= start && new Date(item.end_time) < end
);
}

function groupData(config: ChartConfig, data: DataPoint[]): any {
const groupedData: Record<string, DataPointGroup> = {};

data.forEach((item) => {
// compute the group key based on the groupBy configuration
const groupKeys: GroupKeys = {};
const groupKeysValues: (string | number | null)[] = [];
for (const key of config.groupBy) {
groupKeysValues.push(item[key]);
groupKeys[key] = item[key];
}
const groupKey = groupKeysValues.join("-");

if (!groupedData[groupKey]) {
groupedData[groupKey] = { rawData: [], groupKeys, values: {} };
// set the values for the group using the GroupValueSettersConfig
for (const key of config.groupBy) {
for (const valueToSet of GroupValueSettersConfig[key]) {
groupedData[groupKey].values[valueToSet] = item[valueToSet];
}
}
}
// only add the item if that group does not already contain a datapoint with the same time_entry_id
if (
!groupedData[groupKey].rawData.some(
(d) => d.time_entry_id === item.time_entry_id
)
) {
// push the raw item to the group
groupedData[groupKey].rawData.push(item);
}
});

return groupedData;
}

function aggregateData(
_config: ChartConfig,
groupedData: Record<string, DataPointGroup>
): Record<string, DataPointGroup> {
const aggregatedData: Record<string, DataPointGroup> = {};

for (const groupKey in groupedData) {
const group = groupedData[groupKey];

//compute total duration and count
group.values[DataPointValue.DURATION] = group.rawData.reduce(
(sum, item) => sum + item.duration,
0
);
group.values[DataPointValue.COUNT] = group.rawData.length;

aggregatedData[groupKey] = {
groupKeys: group.groupKeys,
rawData: group.rawData,
values: group.values,
};
}

return aggregatedData;
}

export function getChartData(
config: ChartConfig,
rawData: DataPoint[]
): ChartData {
const selectedData: DataPoint[] = filterData(config, rawData);
const groupedData: Record<string, DataPointGroup> = groupData(
config,
selectedData
);
const aggregatedData: Record<string, DataPointGroup> = aggregateData(
config,
groupedData
);

const chartData: ChartData = {
points: {
x: [],
ys: [],
},
config,
};

const yFields: DataPointValue[] = [config.yAxisField];

const aggregatedDataValues = Object.values(aggregatedData);

const x = aggregatedDataValues.map((item) => {
const labels = config.groupBy.map((key) => {
const aesthetic = GroupKeysAesthetics[key];
return aesthetic.getLabelX(item);
});
// keep unique labels
const uniqueLabels = new Set(labels);
return Array.from(uniqueLabels).join(" - ");
});
const ys = yFields.map((yField) => ({
data: aggregatedDataValues.map((item) => item.values[yField]),
label: DataPointValueAesthetics[yField].description,
backgroundColor: aggregatedDataValues.map((item) =>
String(item.values[DataPointValue.TAG_COLOR] ?? "")
),
}));

chartData.points.x = x;
chartData.points.ys = ys;
return chartData;
}
Loading