First pass

This commit is contained in:
2025-12-25 22:10:06 -07:00
parent a2af6195f9
commit 455c3dbd9a
58 changed files with 10299 additions and 3 deletions

View File

@@ -0,0 +1,62 @@
<template>
<Doughnut :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Doughnut } from 'vue-chartjs';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
DoughnutController
} from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, DoughnutController);
interface CategoryData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
categories: CategoryData[];
}>();
const chartData = computed(() => ({
labels: props.categories.map(c => c.name),
datasets: [{
data: props.categories.map(c => c.totalTime),
backgroundColor: props.categories.map(c => c.color || '#3b82f6'),
borderWidth: 2,
borderColor: '#1e293b',
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
color: '#e2e8f0',
padding: 15,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${context.label}: ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -0,0 +1,83 @@
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
interface ClientData {
name: string;
totalTime: number;
}
const props = defineProps<{
clients: ClientData[];
}>();
const chartData = computed(() => ({
labels: props.clients.map(c => c.name),
datasets: [{
label: 'Time Tracked',
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#6366f1',
borderColor: '#4f46e5',
borderWidth: 1,
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#e2e8f0'
},
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -0,0 +1,84 @@
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
interface MemberData {
name: string;
totalTime: number;
}
const props = defineProps<{
members: MemberData[];
}>();
const chartData = computed(() => ({
labels: props.members.map(m => m.name),
datasets: [{
label: 'Time Tracked',
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#10b981',
borderColor: '#059669',
borderWidth: 1,
}]
}));
const chartOptions = {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
grid: {
color: '#334155'
}
},
y: {
ticks: {
color: '#e2e8f0'
},
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
};
</script>

202
src/components/Timer.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps<{
initialRunningEntry: { startTime: number; description: string | null; clientId: string; categoryId: string } | null;
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
const isRunning = ref(false);
const startTime = ref<number | null>(null);
const elapsedTime = ref(0);
const description = ref('');
const selectedClientId = ref('');
const selectedCategoryId = ref('');
const selectedTags = ref<string[]>([]);
let interval: ReturnType<typeof setInterval> | null = null;
function formatTime(ms: number) {
// Round to nearest minute (10 seconds = 1 minute)
const totalMinutes = Math.round(ms / 1000 / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
} else {
selectedTags.value.push(tagId);
}
}
onMounted(() => {
if (props.initialRunningEntry) {
isRunning.value = true;
startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || '';
selectedClientId.value = props.initialRunningEntry.clientId;
selectedCategoryId.value = props.initialRunningEntry.categoryId;
elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
}, 1000);
}
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
async function startTimer() {
if (!selectedClientId.value) {
alert('Please select a client');
return;
}
if (!selectedCategoryId.value) {
alert('Please select a category');
return;
}
const res = await fetch('/api/time-entries/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
tags: selectedTags.value,
}),
});
if (res.ok) {
const data = await res.json();
startTime.value = new Date(data.startTime).getTime();
isRunning.value = true;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
}, 1000);
}
}
async function stopTimer() {
const res = await fetch('/api/time-entries/stop', {
method: 'POST',
});
if (res.ok) {
isRunning.value = false;
if (interval) clearInterval(interval);
elapsedTime.value = 0;
startTime.value = null;
description.value = '';
selectedClientId.value = '';
selectedCategoryId.value = '';
selectedTags.value = [];
window.location.reload();
}
}
</script>
<template>
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div class="card-body gap-6">
<!-- Client and Description Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Client</span>
</label>
<select
v-model="selectedClientId"
class="select select-bordered w-full"
:disabled="isRunning"
>
<option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Category</span>
</label>
<select
v-model="selectedCategoryId"
class="select select-bordered w-full"
:disabled="isRunning"
>
<option value="">Select a category...</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
</div>
<!-- Description Row -->
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Description</span>
</label>
<input
v-model="description"
type="text"
placeholder="What are you working on?"
class="input input-bordered w-full"
:disabled="isRunning"
/>
</div>
<!-- Tags Section -->
<div v-if="tags.length > 0" class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Tags</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
:key="tag.id"
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all',
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline'
]"
:disabled="isRunning"
type="button"
>
{{ tag.name }}
</button>
</div>
</div>
<!-- Timer and Action Row -->
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
<div class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left flex-grow">
{{ formatTime(elapsedTime) }}
</div>
<button
v-if="!isRunning"
@click="startTimer"
class="btn btn-primary btn-lg min-w-40"
>
Start Timer
</button>
<button
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40"
>
Stop Timer
</button>
</div>
</div>
</div>
</template>