diff --git a/backend/supabase/migrations/20250720082656_add_time_entry_report_view.sql b/backend/supabase/migrations/20250720082656_add_time_entry_report_view.sql new file mode 100644 index 0000000..96cc42e --- /dev/null +++ b/backend/supabase/migrations/20250720082656_add_time_entry_report_view.sql @@ -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(); + + diff --git a/backend/supabase/migrations/20250720090624_fix_time_entry_report_issues.sql b/backend/supabase/migrations/20250720090624_fix_time_entry_report_issues.sql new file mode 100644 index 0000000..c0566cd --- /dev/null +++ b/backend/supabase/migrations/20250720090624_fix_time_entry_report_issues.sql @@ -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; diff --git a/backend/supabase/migrations/20250720092408_fix_refresh_time_entry_report_issues.sql b/backend/supabase/migrations/20250720092408_fix_refresh_time_entry_report_issues.sql new file mode 100644 index 0000000..8e079e3 --- /dev/null +++ b/backend/supabase/migrations/20250720092408_fix_refresh_time_entry_report_issues.sql @@ -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; \ No newline at end of file diff --git a/frontend/simple-tracker/src/App.vue b/frontend/simple-tracker/src/App.vue index 121bde3..f9fa271 100644 --- a/frontend/simple-tracker/src/App.vue +++ b/frontend/simple-tracker/src/App.vue @@ -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({ @@ -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; diff --git a/frontend/simple-tracker/src/common/charts/charts.supabase.ts b/frontend/simple-tracker/src/common/charts/charts.supabase.ts new file mode 100644 index 0000000..004c327 --- /dev/null +++ b/frontend/simple-tracker/src/common/charts/charts.supabase.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/frontend/simple-tracker/src/common/charts/charts.ts b/frontend/simple-tracker/src/common/charts/charts.ts new file mode 100644 index 0000000..992cd30 --- /dev/null +++ b/frontend/simple-tracker/src/common/charts/charts.ts @@ -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 = {}; + + 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 +): Record { + const aggregatedData: Record = {}; + + 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 = groupData( + config, + selectedData + ); + const aggregatedData: Record = 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; +} diff --git a/frontend/simple-tracker/src/common/charts/charts.types.ts b/frontend/simple-tracker/src/common/charts/charts.types.ts new file mode 100644 index 0000000..7bbccec --- /dev/null +++ b/frontend/simple-tracker/src/common/charts/charts.types.ts @@ -0,0 +1,313 @@ +import { toDurationString } from "../timeUtils"; +import { getMonthName, getWeekdayName } from "./charts.utils"; + +export interface DataPoint { + time_entry_id: number; + time_entry_notes: string | null; + task_id: string; + start_time: number; + end_time: number; + task_name: string; + tag_id: string | null; + tag_name: string | null; + tag_color: string | null; + tag_dot_text: string | null; + duration: number; + weekday: number; + month: number; + year: number; + count: 1; +} +// Helper type to extract keys of T whose property values are assignable to `number`. +type NumericKeys = { + [K in keyof T]: T[K] extends number ? K : never; +}[keyof T]; +// This creates a type that is a union of all property names in DataPoint that are numbers. +type NumericDataPointKeys = NumericKeys; +export const NumericDataPointValues: NumericDataPointKeys[] = [ + "time_entry_id", + "duration", + "weekday", + "month", + "year", + "count", + "start_time", + "end_time", +]; + +export enum PeriodType { + TODAY = "Today", + YESTERDAY = "Yesterday", + THIS_WEEK = "This week", + LAST_WEEK = "Last week", + THIS_MONTH = "This month", + LAST_MONTH = "Last month", + THIS_YEAR = "This year", + LAST_YEAR = "Last year", +} + +export const DataPointValue = { + TASK_NAME: "task_name", + TAG_NAME: "tag_name", + TAG_COLOR: "tag_color", + TAG_DOT_TEXT: "tag_dot_text", + DURATION: "duration", + WEEKDAY: "weekday", + MONTH: "month", + YEAR: "year", + COUNT: "count", + START_TIME: "start_time", + END_TIME: "end_time", +} as const satisfies Record; +export type DataPointValue = + (typeof DataPointValue)[keyof typeof DataPointValue]; + +export const GroupKey = { + ENTRY: "time_entry_id", + TASK: "task_id", + TAG: "tag_id", + WEEKDAY: "weekday", + MONTH: "month", + YEAR: "year", +} as const satisfies Record; +export type GroupKey = (typeof GroupKey)[keyof typeof GroupKey]; + +export type GroupKeys = { + [key in GroupKey]?: string | number | null; +}; + +/** + * Configuration for which data points should be set for each group key. + * for example, for the TASK group key, we want to set the task name. + * for the TAG group key, we want to set the tag name, tag color, ... + */ +export const GroupValueSettersConfig = { + [GroupKey.ENTRY]: [ + DataPointValue.TASK_NAME, + DataPointValue.WEEKDAY, + DataPointValue.MONTH, + DataPointValue.YEAR, + DataPointValue.START_TIME, + DataPointValue.END_TIME, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], + [GroupKey.TASK]: [ + DataPointValue.TASK_NAME, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], + [GroupKey.TAG]: [ + DataPointValue.TAG_NAME, + DataPointValue.TAG_COLOR, + DataPointValue.TAG_DOT_TEXT, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], + [GroupKey.WEEKDAY]: [ + DataPointValue.WEEKDAY, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], + [GroupKey.MONTH]: [ + DataPointValue.MONTH, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], + [GroupKey.YEAR]: [ + DataPointValue.YEAR, + DataPointValue.DURATION, + DataPointValue.COUNT, + ], +} as const satisfies Record; + +export interface DataPointGroup { + groupKeys: GroupKeys; + rawData: DataPoint[]; + values: { + [key in DataPointValue]?: string | number | null; + }; +} + +export enum ChartType { + BAR = "Bar", + LINE = "Line", + DOUGHNUT = "Doughnut", +} + +export interface ChartConfig { + title: string; + description: string; + chartType: ChartType; + periodType: PeriodType; + groupBy: GroupKey[]; + xAxisField: DataPointValue; + yAxisField: DataPointValue; +} + +export function getDefaultChartConfig(): ChartConfig { + return { + title: "", + description: "", + chartType: ChartType.BAR, + periodType: PeriodType.THIS_WEEK, + groupBy: [GroupKey.TASK], + xAxisField: DataPointValue.TASK_NAME, + yAxisField: DataPointValue.DURATION, + }; +} + +export interface ChartData { + points: { + x: any[]; + ys: { + data: any[]; + label: string; + backgroundColor: string[]; + }[]; + }; + config: ChartConfig; +} + +export interface DataPointValueAesthetic { + getLabelX: (dataPointGroup: DataPointGroup) => string; + getLabelY: (dataPointGroup: DataPointGroup) => string; + getTickLabel: (value: any) => string; + description: string; +} + +export const DataPointValueAesthetics = { + [DataPointValue.COUNT]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.COUNT]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.COUNT]), + getTickLabel: (value: any) => String(value), + description: "Count", + }, + [DataPointValue.DURATION]: { + getLabelX: (dataPointGroup: DataPointGroup) => + toDurationString( + new Date(dataPointGroup.values[DataPointValue.DURATION] as number) + ), + getLabelY: (dataPointGroup: DataPointGroup) => + toDurationString( + new Date(dataPointGroup.values[DataPointValue.DURATION] as number) + ), + getTickLabel: (value: any) => toDurationString(value), + description: "Duration", + }, + [DataPointValue.TASK_NAME]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TASK_NAME]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TASK_NAME]), + getTickLabel: (value: any) => String(value), + description: "Task Name", + }, + [DataPointValue.TAG_NAME]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_NAME] ?? "untagged"), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_NAME] ?? "untagged"), + getTickLabel: (value: any) => String(value), + description: "Tag Name", + }, + [DataPointValue.TAG_COLOR]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_COLOR]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_COLOR]), + getTickLabel: (value: any) => String(value), + description: "Tag Color", + }, + [DataPointValue.TAG_DOT_TEXT]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_DOT_TEXT]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_DOT_TEXT]), + getTickLabel: (value: any) => String(value), + description: "Tag Dot Text", + }, + [DataPointValue.WEEKDAY]: { + getLabelX: (dataPointGroup: DataPointGroup) => + getWeekdayName(Number(dataPointGroup.values[DataPointValue.WEEKDAY])), + getLabelY: (dataPointGroup: DataPointGroup) => + getWeekdayName(Number(dataPointGroup.values[DataPointValue.WEEKDAY])), + getTickLabel: (value: any) => getWeekdayName(Number(value)), + description: "Weekday", + }, + [DataPointValue.MONTH]: { + getLabelX: (dataPointGroup: DataPointGroup) => + getMonthName(Number(dataPointGroup.values[DataPointValue.MONTH])), + getLabelY: (dataPointGroup: DataPointGroup) => + getMonthName(Number(dataPointGroup.values[DataPointValue.MONTH])), + getTickLabel: (value: any) => getMonthName(Number(value)), + description: "Month", + }, + [DataPointValue.YEAR]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.YEAR]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.YEAR]), + getTickLabel: (value: any) => String(value), + description: "Year", + }, + [DataPointValue.START_TIME]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.START_TIME]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.START_TIME]), + getTickLabel: (value: any) => new Date(value).toLocaleString(), + description: "Start Time", + }, + [DataPointValue.END_TIME]: { + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.END_TIME]), + getLabelY: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.END_TIME]), + getTickLabel: (value: any) => new Date(value).toLocaleString(), + description: "End Time", + }, +} as const satisfies Record; + +export const GroupKeysAesthetics = { + [GroupKey.ENTRY]: { + description: "Time entry", + getLabelX: (dataPointGroup: DataPointGroup) => + new Date( + Number(dataPointGroup.values[DataPointValue.START_TIME]) + ).toLocaleString(), + }, + [GroupKey.TASK]: { + description: "Task", + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TASK_NAME]), + }, + [GroupKey.TAG]: { + description: "Tag", + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.TAG_NAME] ?? "untagged"), + }, + [GroupKey.WEEKDAY]: { + description: "Weekday", + getLabelX: (dataPointGroup: DataPointGroup) => + getWeekdayName(Number(dataPointGroup.values[DataPointValue.WEEKDAY])), + }, + [GroupKey.MONTH]: { + description: "Month", + getLabelX: (dataPointGroup: DataPointGroup) => + getMonthName(Number(dataPointGroup.values[DataPointValue.MONTH])), + }, + [GroupKey.YEAR]: { + description: "Year", + getLabelX: (dataPointGroup: DataPointGroup) => + String(dataPointGroup.values[DataPointValue.YEAR]), + }, +} as const satisfies Record< + GroupKey, + { + description: string; + getLabelX: (dataPointGroup: DataPointGroup) => string; + } +>; diff --git a/frontend/simple-tracker/src/common/charts/charts.utils.ts b/frontend/simple-tracker/src/common/charts/charts.utils.ts new file mode 100644 index 0000000..48d6770 --- /dev/null +++ b/frontend/simple-tracker/src/common/charts/charts.utils.ts @@ -0,0 +1,119 @@ +import { + GroupKey, + PeriodType, + GroupValueSettersConfig, + DataPointValue, + NumericDataPointValues, +} from "./charts.types"; + +export function getDateIntevealFromPeriodType(periodType: PeriodType): { + start: Date; + end: Date; +} { + let now = new Date(); + now = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let start: Date; + let end: Date; + + switch (periodType) { + case PeriodType.TODAY: + start = now; + end = new Date(start); + end.setDate(end.getDate() + 1); + break; + case PeriodType.YESTERDAY: + start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + end = new Date(start); + end.setDate(end.getDate() + 1); + break; + case PeriodType.THIS_WEEK: + start = new Date(now); + start.setDate(start.getDate() - start.getDay()); + end = new Date(start); + end.setDate(end.getDate() + 7); + break; + case PeriodType.LAST_WEEK: + start = new Date(now); + start.setDate(start.getDate() - (start.getDay() + 7)); + end = new Date(start); + end.setDate(end.getDate() + 7); + break; + case PeriodType.THIS_MONTH: + start = new Date(now.getFullYear(), now.getMonth(), 1); + end = new Date(now.getFullYear(), now.getMonth() + 1, 1); + break; + case PeriodType.LAST_MONTH: + start = new Date(now.getFullYear(), now.getMonth() - 1, 1); + end = new Date(now.getFullYear(), now.getMonth(), 1); + break; + case PeriodType.THIS_YEAR: + start = new Date(now.getFullYear(), 0, 1); + end = new Date(now.getFullYear() + 1, 0, 1); + break; + case PeriodType.LAST_YEAR: + start = new Date(now.getFullYear() - 1, 0, 1); + end = new Date(now.getFullYear(), 0, 1); + break; + default: + throw new Error("Invalid period type"); + } + + return { start, end }; +} + +export function getAllowedXFields(GroupKeys: GroupKey[]): DataPointValue[] { + // allowed x fields are the ones that are brought in by the group keys + const allowedXFields: DataPointValue[] = []; + + GroupKeys.forEach((key) => { + const fields = GroupValueSettersConfig[key]; + //merge the fields into the allowedXFields array + fields.forEach((field) => { + if (!allowedXFields.includes(field)) { + allowedXFields.push(field); + } + }); + }); + + return allowedXFields; +} + +export function getAllowedYFields(GroupKeys: GroupKey[]): DataPointValue[] { + // allowed y fields are the subset of numeric fields allowed in the X axis + const allowedXFields = getAllowedXFields(GroupKeys); + return allowedXFields.filter((field) => + //check if field is allowed as a numeric data point value + (NumericDataPointValues as readonly DataPointValue[]).includes(field) + ); +} + +export function getWeekdayName(weekday: number): string { + const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + return weekdays[weekday] || ""; +} + +export function getMonthName(month: number): string { + const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + return months[month - 1] || ""; +} diff --git a/frontend/simple-tracker/src/components/AppButton.vue b/frontend/simple-tracker/src/components/AppButton.vue new file mode 100644 index 0000000..294910d --- /dev/null +++ b/frontend/simple-tracker/src/components/AppButton.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/simple-tracker/src/components/AppNavigation.vue b/frontend/simple-tracker/src/components/AppNavigation.vue index b9fc1b9..9e777a7 100644 --- a/frontend/simple-tracker/src/components/AppNavigation.vue +++ b/frontend/simple-tracker/src/components/AppNavigation.vue @@ -29,6 +29,15 @@ > + + +
+ +
diff --git a/frontend/simple-tracker/src/components/charts/AppBarChart.vue b/frontend/simple-tracker/src/components/charts/AppBarChart.vue new file mode 100644 index 0000000..26d5a52 --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppBarChart.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/simple-tracker/src/components/charts/AppChart.vue b/frontend/simple-tracker/src/components/charts/AppChart.vue new file mode 100644 index 0000000..a05dab5 --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppChart.vue @@ -0,0 +1,43 @@ + + diff --git a/frontend/simple-tracker/src/components/charts/AppDoughnutChart.vue b/frontend/simple-tracker/src/components/charts/AppDoughnutChart.vue new file mode 100644 index 0000000..2583f4b --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppDoughnutChart.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/simple-tracker/src/components/charts/AppEditChart.vue b/frontend/simple-tracker/src/components/charts/AppEditChart.vue new file mode 100644 index 0000000..81ea553 --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppEditChart.vue @@ -0,0 +1,237 @@ + + + diff --git a/frontend/simple-tracker/src/components/charts/AppLineChart.vue b/frontend/simple-tracker/src/components/charts/AppLineChart.vue new file mode 100644 index 0000000..3497cec --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppLineChart.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/simple-tracker/src/components/charts/AppPageCharts.vue b/frontend/simple-tracker/src/components/charts/AppPageCharts.vue new file mode 100644 index 0000000..dcb4d17 --- /dev/null +++ b/frontend/simple-tracker/src/components/charts/AppPageCharts.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/simple-tracker/src/stores/chartData.ts b/frontend/simple-tracker/src/stores/chartData.ts new file mode 100644 index 0000000..693a810 --- /dev/null +++ b/frontend/simple-tracker/src/stores/chartData.ts @@ -0,0 +1,21 @@ +import { defineStore } from "pinia"; +import { type DataPoint } from "../common/charts/charts.types"; +import { fetchRawData } from "../common/charts/charts.supabase"; +import { ref } from "vue"; + +export const useChartDataStore = defineStore("chartData", () => { + const rawChartData = ref([]); + const isLoading = ref(false); + + async function refreshChartData() { + isLoading.value = true; + const data = await fetchRawData(); + rawChartData.value = data; + isLoading.value = false; + } + + return { + rawChartData, + refreshChartData, + }; +}); diff --git a/frontend/simple-tracker/src/stores/chartHelpers.ts b/frontend/simple-tracker/src/stores/chartHelpers.ts new file mode 100644 index 0000000..b78d2fc --- /dev/null +++ b/frontend/simple-tracker/src/stores/chartHelpers.ts @@ -0,0 +1,51 @@ +import { defineStore } from "pinia"; +import { computed } from "vue"; +import { useStyleStore } from "./style"; +import { + DataPointValueAesthetics, + type ChartConfig, +} from "../common/charts/charts.types"; + +export const useChartHelpersStore = defineStore("chartHelpers", () => { + const styleStore = useStyleStore(); + + const chartTextColor = computed(() => { + return styleStore.getTextColor(); + }); + const chartGridColor = computed(() => { + return chartTextColor.value + "40"; + }); + + const chartTooltipConfig = (chartConfig: ChartConfig) => ({ + callbacks: { + label: (context: any) => { + const label = context.dataset.label || ""; + const value = context.raw; + return `${label}: ${DataPointValueAesthetics[ + chartConfig.yAxisField + ].getTickLabel(value)}`; + }, + }, + }); + + const chartTicksConfigY = (chartConfig: ChartConfig) => ({ + color: chartTextColor.value, + callback: (value: any) => { + return chartConfig.yAxisField + ? DataPointValueAesthetics[chartConfig.yAxisField].getTickLabel(value) + : value.toString(); + }, + }); + + const chartTicksConfigX = (_chartConfig: ChartConfig) => ({ + color: chartTextColor.value, + }); + + return { + chartTextColor, + chartGridColor, + chartTooltipConfig, + chartTicksConfigY, + chartTicksConfigX, + }; +}); diff --git a/frontend/simple-tracker/src/stores/charts.ts b/frontend/simple-tracker/src/stores/charts.ts new file mode 100644 index 0000000..610fced --- /dev/null +++ b/frontend/simple-tracker/src/stores/charts.ts @@ -0,0 +1,56 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { + type ChartConfigRecord, + saveChartConfig, + fetchChartConfigs, + deleteChartConfig, +} from "../common/charts/charts.supabase"; + +/** + * this store holds the users chart configurations. + * It allows to fetch, save and update chart configurations. + */ +export const useChartsStore = defineStore("charts", () => { + const chartConfigs = ref([]); + + async function fetchConfigs() { + chartConfigs.value = await fetchChartConfigs(); + } + + async function saveConfig( + chartConfig: ChartConfigRecord + ): Promise { + const updatedRecord = await saveChartConfig(chartConfig); + if (updatedRecord) { + // upsert to the local store + const index = chartConfigs.value.findIndex( + (config) => config.id === updatedRecord.id + ); + if (index !== -1) { + chartConfigs.value[index] = updatedRecord; + } else { + chartConfigs.value.push(updatedRecord); + } + } + return updatedRecord; + } + + async function deleteConfig(id: string): Promise { + const success = await deleteChartConfig(id); + if (success) { + const index = chartConfigs.value.findIndex((config) => config.id === id); + if (index !== -1) { + chartConfigs.value.splice(index, 1); + } + } + return success; + } + + return { + chartConfigs, + fetchConfigs, + deleteConfig, + saveConfig, + }; +}); diff --git a/frontend/simple-tracker/src/stores/style.ts b/frontend/simple-tracker/src/stores/style.ts index 186a8c3..ab23e43 100644 --- a/frontend/simple-tracker/src/stores/style.ts +++ b/frontend/simple-tracker/src/stores/style.ts @@ -29,8 +29,50 @@ export const useStyleStore = defineStore("style", () => { return primaryColor; } + function getTextColor(): string { + //find the div with the id "main-div" + const appDiv = document.getElementById(MAIN_DIV_ID); + if (!appDiv) { + throw new Error("app main-div not found"); + } + //create a div with the id "probe" and append it to the app div + const probeDiv = document.createElement("div"); + probeDiv.id = PROBE_DIV_ID; + appDiv.appendChild(probeDiv); + const computedStyle = getComputedStyle(probeDiv); + const textColor = computedStyle.getPropertyValue("--text"); + //remove the probe div + appDiv.removeChild(probeDiv); + //remove the probe div + probeDiv.remove(); + //return the text color + return textColor; + } + + function getFontSizePx(): number { + //find the div with the id "main-div" + const appDiv = document.getElementById(MAIN_DIV_ID); + if (!appDiv) { + throw new Error("app main-div not found"); + } + //create a div with the id "probe" and append it to the app div + const probeDiv = document.createElement("div"); + probeDiv.id = PROBE_DIV_ID; + appDiv.appendChild(probeDiv); + const computedStyle = getComputedStyle(probeDiv); + const fontSize = computedStyle.getPropertyValue("font-size"); + //remove the probe div + appDiv.removeChild(probeDiv); + //remove the probe div + probeDiv.remove(); + //return the font size in pixels + return parseFloat(fontSize); + } + return { MAIN_DIV_ID, getPrimaryColor, + getTextColor, + getFontSizePx, }; }); diff --git a/frontend/simple-tracker/src/stores/tests/charts.test.ts b/frontend/simple-tracker/src/stores/tests/charts.test.ts new file mode 100644 index 0000000..21f2cc9 --- /dev/null +++ b/frontend/simple-tracker/src/stores/tests/charts.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useChartsStore } from "../charts"; +import * as chartsSupabase from "../../common/charts/charts.supabase"; +import type { ChartConfigRecord } from "../../common/charts/charts.supabase"; +import { + ChartType, + DataPointValue, + GroupKey, + PeriodType, +} from "../../common/charts/charts.types"; + +vi.mock("../../common/charts/charts.supabase", () => ({ + fetchChartConfigs: vi.fn(), + saveChartConfig: vi.fn(), + deleteChartConfig: vi.fn(), +})); + +const createMockChartConfig = (id: string): ChartConfigRecord => ({ + id, + user_id: "user-1", + created_at: new Date().toISOString(), + chart_config: { + title: `Chart ${id}`, + description: `Description for chart ${id}`, + chartType: ChartType.BAR, + periodType: PeriodType.THIS_WEEK, + groupBy: [GroupKey.TASK], + xAxisField: DataPointValue.TASK_NAME, + yAxisField: DataPointValue.DURATION, + }, +}); + +describe("useChartStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it("should initialize with an empty chartConfigs array", () => { + const store = useChartsStore(); + expect(store.chartConfigs).toEqual([]); + }); + + describe("fetchConfigs", () => { + it("should fetch chart configurations and update the store", async () => { + const mockConfigs = [ + createMockChartConfig("1"), + createMockChartConfig("2"), + ]; + vi.mocked(chartsSupabase.fetchChartConfigs).mockResolvedValue( + mockConfigs + ); + + const store = useChartsStore(); + await store.fetchConfigs(); + + expect(chartsSupabase.fetchChartConfigs).toHaveBeenCalledTimes(1); + expect(store.chartConfigs).toEqual(mockConfigs); + }); + + it("should handle an empty array from fetchChartConfigs", async () => { + vi.mocked(chartsSupabase.fetchChartConfigs).mockResolvedValue([]); + + const store = useChartsStore(); + await store.fetchConfigs(); + + expect(store.chartConfigs).toEqual([]); + }); + }); + + describe("saveConfig", () => { + it("should add a new config when saveChartConfig succeeds", async () => { + const newConfig = createMockChartConfig("1"); + const mockResponse = { + ...newConfig, + created_at: new Date().toISOString(), + }; + vi.mocked(chartsSupabase.saveChartConfig).mockResolvedValue(mockResponse); + + const store = useChartsStore(); + await store.saveConfig(newConfig); + + expect(chartsSupabase.saveChartConfig).toHaveBeenCalledWith(newConfig); + expect(store.chartConfigs).toContainEqual(mockResponse); + expect(store.chartConfigs).toHaveLength(1); + }); + + it("should update an existing config when saveChartConfig succeeds", async () => { + const existingConfig = createMockChartConfig("1"); + const updatedConfig = { + ...existingConfig, + chart_config: { + ...existingConfig.chart_config, + title: "Updated Chart", + }, + }; + vi.mocked(chartsSupabase.saveChartConfig).mockResolvedValue( + updatedConfig + ); + + const store = useChartsStore(); + store.chartConfigs = [existingConfig]; + + await store.saveConfig(updatedConfig); + + expect(chartsSupabase.saveChartConfig).toHaveBeenCalledWith( + updatedConfig + ); + expect(store.chartConfigs).toContainEqual(updatedConfig); + expect(store.chartConfigs).not.toContainEqual(existingConfig); + expect(store.chartConfigs).toHaveLength(1); + }); + + it("should not modify the store if saveChartConfig fails", async () => { + const newConfig = createMockChartConfig("1"); + vi.mocked(chartsSupabase.saveChartConfig).mockRejectedValue( + new Error("Save failed") + ); + const store = useChartsStore(); + await expect(store.saveConfig(newConfig)).rejects.toThrow("Save failed"); + expect(chartsSupabase.saveChartConfig).toHaveBeenCalledWith(newConfig); + expect(store.chartConfigs).toEqual([]); + }); + + describe("deleteConfig", () => { + it("should remove a config when deleteChartConfig succeeds", async () => { + const existingConfig = createMockChartConfig("1"); + vi.mocked(chartsSupabase.deleteChartConfig).mockResolvedValue(true); + + const store = useChartsStore(); + store.chartConfigs = [existingConfig]; + + const success = await store.deleteConfig(existingConfig.id!); + + expect(chartsSupabase.deleteChartConfig).toHaveBeenCalledWith( + existingConfig.id + ); + expect(success).toBe(true); + expect(store.chartConfigs).toEqual([]); + }); + + it("should not modify the store if deleteChartConfig fails", async () => { + const existingConfig = createMockChartConfig("1"); + vi.mocked(chartsSupabase.deleteChartConfig).mockResolvedValue(false); + + const store = useChartsStore(); + store.chartConfigs = [existingConfig]; + + const success = await store.deleteConfig(existingConfig.id!); + + expect(chartsSupabase.deleteChartConfig).toHaveBeenCalledWith( + existingConfig.id + ); + expect(success).toBe(false); + expect(store.chartConfigs).toEqual([existingConfig]); + }); + }); + }); +});