Compare commits

...
14 Commits
Author SHA1 Message Date
joshuacoles 026d0cfd56 Add macos build for arm
Build and Publish Docker Container / build-arm (push) Successful in 1m50s
Build and Publish Docker Container / build (push) Successful in 3m44s
2024-03-04 19:46:13 +00:00
joshuacoles 1a14485c57 Update reads to toggl database 2024-03-04 19:46:07 +00:00
joshuacoles 50730a2b09 Simplify calendar code 2024-03-03 18:57:03 +00:00
joshuacoles 9fc14cffd0 Add Caddyfile to gitignore 2024-03-03 18:47:18 +00:00
joshuacoles e534506894 Update build.yml 2024-02-28 17:20:42 +00:00
joshuacoles 89558cdd1a Remove unnecessary user interaction with donut chart
Build and Publish Docker Container / build (push) Successful in 4m22s
2024-02-28 17:18:56 +00:00
joshuacoles d6b319e6a5 Colour weekends differently by default 2024-02-28 16:49:35 +00:00
joshuacoles f350018eb2 Add support for penalising overtime to the heatmap
Build and Publish Docker Container / build (push) Successful in 3m44s
2024-02-28 16:43:40 +00:00
joshuacoles 65e8899426 Cleanup imports
Build and Publish Docker Container / build (push) Successful in 3m17s
2024-02-25 19:58:31 +00:00
joshuacoles ec91f6df42 Fix sizing of the heatmap and make goal configurable 2024-02-25 19:57:46 +00:00
joshuacoles f2dd382cf3 Improve the heatmap 2024-02-25 19:53:58 +00:00
joshuacoles 058466294b Initial import of AI heatmap 2024-02-25 19:24:58 +00:00
joshuacoles cf1788ebcd Remove testing donut page
Build and Publish Docker Container / build (push) Successful in 3m12s
2024-02-22 14:57:43 +00:00
joshuacoles e67e01fd0a Add Donut component that can render server side (if that ever actually works) 2024-02-22 14:57:30 +00:00
12 changed files with 382 additions and 114 deletions
+27 -1
View File
@@ -25,9 +25,35 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker image -- finn-board - name: Build and Push Docker image
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
with: with:
context: . context: .
push: true push: true
tags: git.joshuacoles.me/joshuacoles/revision-ui:latest tags: git.joshuacoles.me/joshuacoles/revision-ui:latest
build-arm:
runs-on: macos-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker
uses: docker/login-action@v1
with:
registry: git.joshuacoles.me
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker image
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
with:
context: .
push: true
tags: git.joshuacoles.me/joshuacoles/revision-ui:arm
+3
View File
@@ -40,3 +40,6 @@ next-env.d.ts
# Private env files # Private env files
.env .env
# Caddyfile for local development when testing weird deploys
/Caddyfile
+96
View File
@@ -0,0 +1,96 @@
import * as R from "ramda";
import DonutTooltip from "@/components/DonutTooltip";
import './donut-styles.css'
export type DonutChartData = {
label: string;
colour: string;
value: number;
}[];
export function Donut({
data,
centerLabel,
formatter,
hoverSuffix
}: {
data: DonutChartData,
formatter: (value: number) => string,
centerLabel: string,
hoverSuffix: string,
}) {
const radius = 100 / (2 * Math.PI);
const backgroundColour = "#d2d3d4";
const preppedData = R.sortBy(R.compose(R.negate, R.prop('value')), data);
const total = R.sum(R.pluck('value', preppedData));
const normalisingFactor = 100 / total;
return (
<>
<DonutTooltip/>
<svg width="100%" height="100%" viewBox="0 0 42 42" className="donut">
{/* The hole in the middle of the donut */}
<circle
className="donut-hole"
cx="21"
cy="21"
r={radius}
fill="#fff"
/>
{/* The ring in the background of the donut, if it was not 100% full */}
<circle
className="donut-ring"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={backgroundColour}
strokeWidth="3"
/>
{
preppedData.map((segment, index, array) => {
const {
label,
colour,
value
} = segment;
const precedingSegments = array.slice(0, index);
const offset = precedingSegments.reduce((acc, segment) => acc + segment.value, 0);
return (
<circle
data-tooltip-id="donut-tooltip"
data-tooltip-content={`${label}: ${formatter(value)}${hoverSuffix}`}
data-tooltip-place="top"
key={index}
className="donut-segment"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={colour}
strokeWidth="3"
strokeDasharray={`${value * normalisingFactor} ${100 - value * normalisingFactor}`}
strokeDashoffset={100 - (offset * normalisingFactor) + 25}
style={{outline: 'none'}}
/>
)
})
}
<g className="chart-text" style={{ userSelect: 'none'}}>
<text x="50%" y="50%" className="chart-number">
{formatter(total)}
</text>
<text x="50%" y="50%" className="chart-label">
{centerLabel}
</text>
</g>
</svg>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import {Tooltip} from "react-tooltip";
export default function DonutTooltip() {
return <Tooltip id="donut-tooltip"/>
}
+159
View File
@@ -0,0 +1,159 @@
import React from "react";
import * as dFns from "date-fns";
import * as R from 'ramda';
interface DateValue {
date: Date;
count: number;
}
interface Props {
startDate: Date;
endDate: Date;
data: DateValue[];
className?: string;
goal: number;
penaliseOvertime?: boolean;
}
function GitHubCalendarHeatmap({
startDate,
endDate,
data,
className,
goal,
penaliseOvertime = false,
}: Props) {
const numDays = dFns.differenceInDays(endDate, startDate);
// Calculate the number of weeks in the range
const numWeeks = Math.ceil(numDays / 7);
// Calculate the size of each square in the heatmap and the padding between each square
const squareSize = 15;
const padding = 2;
const weekTotalPadding = 10; // Extra padding for the week total row
const totalWidth = numWeeks * (squareSize + padding);
const totalHeight = 8 * (squareSize + padding) + weekTotalPadding; // Added an extra row for week total
// Goal times for the daily and weekly squares
const dayGoalTime = goal;
const weekGoalTime = 5 * dayGoalTime;
const getColorForValue = (value: number, goalTime: number, zeroColor: string): string => {
const buckets = [
{
min: -Infinity,
max: 0,
color: zeroColor,
},
{
min: 0,
max: 0.40,
color: '#d6e685',
},
{
min: 0.40,
max: 0.50,
color: '#8cc665',
},
{
min: 0.50,
max: 0.90,
color: '#44a340',
},
{
min: 0.90,
// If penalising overtime, the max is 1.125, otherwise it's infinity (ie this is the last bucket)
max: penaliseOvertime ? 1.125 : Infinity,
color: '#1e6823',
},
{
min: 1.125,
max: 1.25,
color: '#f59e0b'
},
{
min: 1.25,
max: Infinity,
color: '#ef4444'
},
];
const linearValue = value / goalTime;
return R.find(minMax => minMax.min < linearValue && linearValue <= minMax.max, buckets)!.color;
};
const getWeekTotal = (weekIndex: number): number => {
const weekStart = dFns.addWeeks(dFns.startOfWeek(startDate, {weekStartsOn: 1}), weekIndex);
return data.filter(dv => dFns.isSameWeek(dv.date, weekStart, {weekStartsOn: 1}))
.reduce((total, {count}) => total + count, 0);
};
const dateValues: DateValue[] = dFns.eachDayOfInterval({
start: startDate,
end: endDate,
}).map((date) => {
const foundData = data.find(
({date: dataDate}) => dFns.isEqual(dFns.startOfDay(date), dFns.startOfDay(dataDate))
);
return {
date,
count: foundData ? foundData.count : 0,
}
});
return (
<svg width="100%" height="100%" viewBox={`0 0 ${totalWidth} ${totalHeight}`} className={className}>
{dateValues.map(({
date,
count
}) => {
const weekIndex = dFns.differenceInCalendarWeeks(date, startDate, {weekStartsOn: 1});
const dayOfWeek = (date.getDay() + 6) % 7;
const x = weekIndex * (squareSize + padding);
const y = dayOfWeek * (squareSize + padding);
const color = getColorForValue(count, dayGoalTime, dFns.isWeekend(date) ? '#d4d4d4' : '#eeeeee');
return (
<rect
key={date.toISOString()}
x={x}
y={y}
width={squareSize}
height={squareSize}
fill={color}
data-tooltip-id={`calendar-tooltip`}
data-tooltip-content={count ? `${dFns.format(date, 'EEE do')}: ${count.toFixed(2)} hours` : `${dFns.format(date, 'EEE do')}`}
/>
);
})}
{Array(numWeeks).fill(0)
.map((_, weekIndex) => {
const x = weekIndex * (squareSize + padding);
const y = 7 * (squareSize + padding) + weekTotalPadding; // Position for the week total
const count = getWeekTotal(weekIndex);
const color = getColorForValue(count, weekGoalTime, '#eeeeee');
return (
<rect
key={`week-total-${weekIndex}`}
x={x}
y={y}
width={squareSize}
height={squareSize}
fill={color}
data-tooltip-id={`calendar-tooltip`}
data-tooltip-content={count ? `Week total ${count.toFixed(2)} hours` : ``}
/>
);
})}
</svg>
);
}
export default GitHubCalendarHeatmap;
+2 -1
View File
@@ -34,7 +34,7 @@ export default function OverviewPage({
<main className="m-6"> <main className="m-6">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">{config.title}</h1> <h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">{config.title}</h1>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-4"> <div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{config.subjects.map((subject) => ( {config.subjects.map((subject) => (
<SubjectOverviewCard <SubjectOverviewCard
key={subject.projectId} key={subject.projectId}
@@ -47,6 +47,7 @@ export default function OverviewPage({
<CalendarOverviewCard <CalendarOverviewCard
data={data} data={data}
goal={config.goalHours} goal={config.goalHours}
penaliseOvertime={config.penaliseOvertime}
startTime={config.timePeriod.start} startTime={config.timePeriod.start}
endTime={config.timePeriod.end} endTime={config.timePeriod.end}
/> />
+9 -44
View File
@@ -1,27 +1,11 @@
"use client";
import {Card, Title} from "@tremor/react"; import {Card, Title} from "@tremor/react";
import * as R from 'ramda'; import * as R from 'ramda';
import * as dFns from 'date-fns'; import * as dFns from 'date-fns';
import CalendarHeatmap from 'react-calendar-heatmap';
import 'react-calendar-heatmap/dist/styles.css';
import '../../app/calendar-styles.css'
import {Tooltip} from 'react-tooltip'; import {Tooltip} from 'react-tooltip';
import {Data} from "@/data/fetchData"; import {Data} from "@/data/fetchData";
import HeatMap from "@/components/HeatMap";
const granularity = 4; function useCalendarData(data: Data) {
function computeCompletionShade(value: number, dailyGoal: number) {
const linearValue = Math.round((value / dailyGoal) * granularity);
// If we did something, but not enough to reach the first level, return 1
if (linearValue == 0 && value > 0) return 1;
// Clamp to the granularity
if (linearValue > granularity) return granularity;
return linearValue;
}
function useCalendarData(data: Data, initialDate: Date, endDate: Date) {
const timeEntries = data.timeEntries; const timeEntries = data.timeEntries;
// Group by day, sum up seconds // Group by day, sum up seconds
@@ -33,17 +17,6 @@ function useCalendarData(data: Data, initialDate: Date, endDate: Date) {
return R.sum((entries ?? []).map((entry) => entry.duration)) return R.sum((entries ?? []).map((entry) => entry.duration))
}, grouped); }, grouped);
// Fill in missing days, hacky
dFns.eachDayOfInterval({
start: initialDate,
end: endDate,
}).forEach((date) => {
const key = dFns.formatISO(date);
if (summed[key] == undefined) {
summed[key] = 0;
}
})
return Object.entries(summed) return Object.entries(summed)
.map(([key, value]) => ({ .map(([key, value]) => ({
date: dFns.parseISO(key), date: dFns.parseISO(key),
@@ -54,33 +27,25 @@ function useCalendarData(data: Data, initialDate: Date, endDate: Date) {
export function CalendarOverviewCard({ export function CalendarOverviewCard({
data, data,
goal, goal,
penaliseOvertime,
startTime, startTime,
endTime, endTime,
}: { }: {
data: Data, data: Data,
goal: number, goal: number,
penaliseOvertime?: boolean,
startTime: string, startTime: string,
endTime: string, endTime: string,
}) { }) {
const initialDate = dFns.parseISO(startTime); const initialDate = dFns.parseISO(startTime);
const endDate = dFns.parseISO(endTime); const endDate = dFns.parseISO(endTime);
const calendarData = useCalendarData(data, initialDate, endDate); const calendarData = useCalendarData(data);
return <Card className="col-span-1"> return <Card className="col-span-1">
<Tooltip id="calendar-tooltip"/> <Tooltip id="calendar-tooltip"/>
<Title>Overview</Title> <Title>Semester Overview</Title>
<CalendarHeatmap <div className="m-2">
showWeekdayLabels={true} <HeatMap startDate={initialDate} endDate={endDate} data={calendarData} goal={goal} penaliseOvertime={penaliseOvertime}/>
startDate={initialDate} </div>
endDate={endDate}
values={calendarData}
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0, goal)}`}
tooltipDataAttrs={(value: any) => {
return value.date ? {
'data-tooltip-id': `calendar-tooltip`,
'data-tooltip-content': value.count ? `${dFns.format(value.date, 'EEE do')}: ${value.count.toFixed(2)} hours` : `${dFns.format(value.date, 'EEE do')}`
} : undefined
}}
/>
</Card> </Card>
} }
+10 -19
View File
@@ -1,24 +1,20 @@
"use client"; import {Card, Title} from "@tremor/react";
import {Card, DonutChart, Title} from "@tremor/react";
import * as R from "ramda"; import * as R from "ramda";
import {Data} from "@/data/fetchData"; import {Data} from "@/data/fetchData";
import {Donut, DonutChartData} from "@/components/Donut";
function useBreakdownData(data: Data): { function useBreakdownData(data: Data): DonutChartData {
name: string,
value: number,
colour: string
}[] {
const sorted = R.sortBy(R.prop('projectId'), data.timeEntries); const sorted = R.sortBy(R.prop('projectId'), data.timeEntries);
const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted); const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted);
return grouped return grouped
.map((entries) => { .map((entries) => {
const project = data.projects.find(p => p.projectId === entries[0].projectId)!; const project = data.projects.find(p => p.projectId === entries[0].projectId)!;
const totalDuration = (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0);
return ({ return ({
name: project.name, label: project.name,
value: (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0), value: (totalDuration / (60 * 60)),
colour: project.color, colour: project.color,
}); });
}) })
@@ -30,20 +26,15 @@ export function SubjectComparisonCard({
data: Data, data: Data,
}) { }) {
const breakdownData = useBreakdownData(data); const breakdownData = useBreakdownData(data);
const colours = breakdownData.map((entry) => entry.colour);
const valueFormatter = (number: number) => `${(number / (60 * 60)).toFixed(2)} hours`;
return ( return (
<Card className="col"> <Card className="col">
<Title>Relative Breakdown</Title> <Title>Relative Breakdown</Title>
<DonutChart <Donut
className="mt-6"
data={breakdownData ?? []} data={breakdownData ?? []}
category="value" formatter={v => v.toFixed(2)}
index="name" centerLabel={"hours"}
valueFormatter={valueFormatter} hoverSuffix={"h"}
colors={colours}
/> />
</Card> </Card>
) )
+19
View File
@@ -0,0 +1,19 @@
.chart-text {
font: 16px/1.4em "Montserrat", Arial, sans-serif;
fill: #000;
transform: translateY(0.25em);
}
.chart-number {
font-size: 0.6em;
line-height: 1;
text-anchor: middle;
transform: translateY(-0.25em);
}
.chart-label {
font-size: 0.2em;
text-transform: uppercase;
text-anchor: middle;
transform: translateY(0.7em);
}
+37 -38
View File
@@ -1,8 +1,8 @@
import type {ColumnType, JSONColumnType} from "kysely"; import type {ColumnType} from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>; export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
@@ -11,7 +11,7 @@ export type Json = ColumnType<JsonValue, string, string>;
export type JsonArray = JsonValue[]; export type JsonArray = JsonValue[];
export type JsonObject = { export type JsonObject = {
[K in string]?: JsonValue; [K in string]?: JsonValue;
}; };
export type JsonPrimitive = boolean | number | string | null; export type JsonPrimitive = boolean | number | string | null;
@@ -21,50 +21,49 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Client { export interface Client {
archived: boolean; archived: boolean;
at: Timestamp; at: Timestamp;
id: number; id: number;
name: string; name: string;
server_deleted_at: Timestamp | null; server_deleted_at: Timestamp | null;
workspace_id: number; workspace_id: number;
} }
export interface Project { export interface Project {
active: boolean; active: boolean;
client_id: number | null; client_id: number | null;
id: Generated<number>; color: string;
name: string; id: Generated<number>;
raw_json: JSONColumnType<{ name: string;
color: string; raw_json: Json;
id: number; server_created_at: Timestamp;
name: string; server_deleted_at: Timestamp | null;
}>; server_updated_at: Timestamp;
toggl_id: Int8; toggl_id: Int8;
workspace_id: Int8; workspace_id: Int8;
} }
export interface TimeEntry { export interface TimeEntry {
description: string; description: string;
id: Generated<number>; id: Generated<number>;
project_id: Int8 | null; project_id: Int8 | null;
raw_json: JSONColumnType<{ raw_json: Json;
start: string; server_deleted_at: Timestamp | null;
end: string; server_updated_at: Timestamp;
seconds: number; start: Timestamp;
}>; stop: Timestamp;
start: Timestamp; tags: Generated<Json>;
stop: Timestamp; toggl_id: Int8;
toggl_id: Int8;
} }
export interface TogglPortalSeaqlMigrations { export interface TogglPortalSeaqlMigrations {
applied_at: Int8; applied_at: Int8;
version: string; version: string;
} }
export interface DB { export interface DB {
client: Client; client: Client;
project: Project; project: Project;
time_entry: TimeEntry; time_entry: TimeEntry;
toggl_portal_seaql_migrations: TogglPortalSeaqlMigrations; toggl_portal_seaql_migrations: TogglPortalSeaqlMigrations;
} }
+11 -10
View File
@@ -30,6 +30,7 @@ export interface OverviewConfig {
}[], }[],
goalHours: number, goalHours: number,
penaliseOvertime?: boolean,
timePeriod: { timePeriod: {
start: string, start: string,
@@ -51,12 +52,12 @@ export async function getData(config: OverviewConfig): Promise<Data> {
let projectIds = config.subjects.map((subject) => subject.projectId.toString()); let projectIds = config.subjects.map((subject) => subject.projectId.toString());
const projects = await db.selectFrom('project') const projects = await db.selectFrom('project')
.select('raw_json') .select(['toggl_id', 'name', 'color'])
.where('project.toggl_id', 'in', projectIds) .where('toggl_id', 'in', projectIds)
.execute(); .execute()
const timeEntries = await db.selectFrom('time_entry') const timeEntries = await db.selectFrom('time_entry')
.select(['project_id', 'raw_json']) .select(['project_id', 'start', 'stop'])
.where('project_id', 'in', projectIds) .where('project_id', 'in', projectIds)
.where('start', '>', dFns.parseISO(config.timePeriod.start)) .where('start', '>', dFns.parseISO(config.timePeriod.start))
.where('start', '<', dFns.parseISO(config.timePeriod.end)) .where('start', '<', dFns.parseISO(config.timePeriod.end))
@@ -64,16 +65,16 @@ export async function getData(config: OverviewConfig): Promise<Data> {
return { return {
projects: projects.map((project) => ({ projects: projects.map((project) => ({
projectId: project.raw_json.id, projectId: parseInt(project.toggl_id),
name: project.raw_json.name, name: project.name,
color: project.raw_json.color, color: project.color,
})), })),
timeEntries: timeEntries.map((timeEntry) => ({ timeEntries: timeEntries.map((timeEntry) => ({
projectId: parseInt(timeEntry.project_id!), projectId: parseInt(timeEntry.project_id!),
start: timeEntry.raw_json.start, start: dFns.formatISO(timeEntry.start),
end: timeEntry.raw_json.end, end: dFns.formatISO(timeEntry.stop),
duration: timeEntry.raw_json.seconds, duration: dFns.differenceInSeconds(timeEntry.stop, timeEntry.start)
})), })),
} }
} }
+2 -1
View File
@@ -29,7 +29,8 @@ export const semester1Revision: OverviewConfig = {
export const semester2: OverviewConfig = { export const semester2: OverviewConfig = {
title: 'Semester 2', title: 'Semester 2',
periodKey: 'y5-s2', periodKey: 'y5-s2',
goalHours: 7.5, goalHours: 6,
penaliseOvertime: true,
subjects: [ subjects: [
{ {
projectId: 195754611, projectId: 195754611,