Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 7 additions & 8 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@ import type { Context, MiddlewareHandler } from 'hono';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
import { resolve } from 'node:path';

dotenv.config({ path: resolve(process.cwd(), '.env') });
dotenv.config({ path: resolve(process.cwd(), '../../.env'), override: true });

const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
const PORT = Number(process.env.PORT ?? 3000);
const port = process.env.PORT || 10000;

if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error('SUPABASE_URL and SUPABASE_ANON_KEY must be set');
Expand Down Expand Up @@ -490,6 +485,10 @@ app.post('/api/commands/soft_delete_item', async (c) => handleSoftDeleteItem(c))

app.post('/api/commands/sync_items', (c) => c.json({ count: 0 }));

serve({ fetch: app.fetch, port: PORT }, () => {
console.log(`Hono server running on http://localhost:${PORT}`);
serve({
fetch: app.fetch,
port: Number(port),
hostname: '0.0.0.0'
}, (info) => {
console.log(`Server is listening on http://0.0.0.0:${info.port}`);
});
2 changes: 2 additions & 0 deletions apps/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async function handleLogout() {
console.error('Logout failed:', e);
}
}

</script>

<template>
Expand All @@ -66,6 +67,7 @@ async function handleLogout() {
<span class="user-id">👤 {{ username || 'User' }}</span>
<button @click="handleLogout" class="logout-btn">Logout</button>
</div>

</div>
</header>

Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/api/honoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class HonoClient implements HonoItemsClient {
private tokenGetter: (() => string | null) | null = null;

constructor(baseUrl?: string) {
this.baseUrl = baseUrl || import.meta.env.VITE_HONO_BASE_URL || 'https://localhost:3000';
this.baseUrl = baseUrl || import.meta.env.VITE_HONO_BASE_URL;
}

/**
Expand Down
106 changes: 106 additions & 0 deletions apps/frontend/src/components/ScatterPlot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue';
import type { Item } from '@/types/item';
import type { GraphItem, GraphConfig } from '@/types/graph';
import { useGraph } from '@/composables/useGraph';

const props = defineProps<{
items: Item[]
}>();

const graphConfig: GraphConfig = {
padding: { top: 40, right: 40, bottom: 60, left: 60 },
gridLines: [0, 25, 50, 75, 100],
defaultMotivation: 50
};

const containerRef = ref<HTMLDivElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const hoveredItem = ref<GraphItem | null>(null);

// initialize useGraph composable with canvas ref and items
const { graphItems, updateData, draw } = useGraph(canvasRef, graphConfig);

watch(() => props.items, (newItems) => {
updateData(newItems);
}, { deep: true, immediate: true });

// detect change of dimention with ResizeObserver
let resizeObserver: ResizeObserver | null = null;

const updateCanvasSize = () => {
const canvas = canvasRef.value;
const container = containerRef.value;
if (!canvas || !container) return;

const { width, height } = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;

// set CSS size (logical pixels)
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

// set actual size (physical pixels) for high-DPI rendering
canvas.width = width * dpr;
canvas.height = height * dpr;

// scale the drawing context to account for the device pixel ratio
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(dpr, dpr);

updateData(props.items);
};

const handleMouseMove = (event: MouseEvent) => {
const rect = canvasRef.value?.getBoundingClientRect();
if (!rect) return;

const x = event.clientX - rect.left;
const y = event.clientY - rect.top;

// look for hovered item (within radius + some margin)
hoveredItem.value = graphItems.value.find(item => {
const dx = item.point.x - x;
const dy = item.point.y - y;
return Math.sqrt(dx * dx + dy * dy) < item.radius + 4; // 4px margin for easier hovering
}) || null;
};

const handleMouseLeave = () => {
hoveredItem.value = null;
};

onMounted(() => {
updateCanvasSize();
resizeObserver = new ResizeObserver(updateCanvasSize);
if (containerRef.value) resizeObserver.observe(containerRef.value);
});

onUnmounted(() => {
resizeObserver?.disconnect();
});

watch(() => props.items, (newItems) => {
updateData(newItems);
draw();
}, { deep: true, immediate: true });

</script>

<template>
<div ref="containerRef" class="w-full h-full min-h-[400px] relative overflow-hidden bg-white rounded-xl shadow-inner">
<canvas
ref="canvasRef"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
class="block cursor-crosshair"
></canvas>

<div v-if="hoveredItem"
:style="{ left: hoveredItem.point.x + 10 + 'px', top: hoveredItem.point.y - 40 + 'px' }"
class="absolute z-10 p-2 bg-gray-800 text-white text-xs rounded pointer-events-none shadow-lg whitespace-nowrap">
<strong>{{ hoveredItem.title }}</strong><br/>
Motivation: {{ hoveredItem.motivation ?? 50 }}%
</div>
</div>
</template>
105 changes: 105 additions & 0 deletions apps/frontend/src/composables/useGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { ref, type Ref } from 'vue';
import type { Item } from '@/types/item';
import type { GraphItem, GraphConfig } from '@/types/graph';

export function useGraph(
canvasRef: Ref<HTMLCanvasElement | null>,
config: GraphConfig
) {
// hold GraphItem data as a Ref to trigger reactivity when updated
const graphItems = ref<GraphItem[]>([]);

/**
* load raw items and calculate their positions/colors for graph rendering
*/
const updateData = (rawItems: Item[]) => {
const canvas = canvasRef.value;
if (!canvas) return;

const activeItems = rawItems.filter(i => i.status !== 'done' && !i.deleted_at);
if (activeItems.length === 0) {
graphItems.value = [];
draw();
return;
}

// calculate time range for x-axis scaling
const times = activeItems.map(i => new Date(i.due).getTime());
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const timeRange = (maxTime - minTime) || 86400000;

// calculate graph dimensions accounting for padding
const width = (canvas.width / (window.devicePixelRatio || 1)) - config.padding.left - config.padding.right;
const height = (canvas.height / (window.devicePixelRatio || 1)) - config.padding.top - config.padding.bottom;

// map items to GraphItem with calculated positions and colors
graphItems.value = activeItems.map(item => {
const t = new Date(item.due).getTime();
const x = config.padding.left + ((t - minTime) / timeRange) * width;
const rawMotivation = item.motivation ?? config.defaultMotivation;
const motivationPercent = rawMotivation <= 10 ? rawMotivation * 10 : rawMotivation;
const y = (canvas.height / (window.devicePixelRatio || 1)) - config.padding.bottom - (motivationPercent / 100) * height;

return {
...item,
point: { x, y },
radius: 6,
color: item.status === 'inprogress' ? '#3b82f6' : '#94a3b8'
};
});

draw();
};

/**
* draw the graph on the canvas based on current graphItems and config
*/
const draw = () => {
const canvas = canvasRef.value;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;

const dpr = window.devicePixelRatio || 1;
const logicalWidth = canvas.width / dpr;
const logicalHeight = canvas.height / dpr;

ctx.clearRect(0, 0, logicalWidth, logicalHeight);

drawGrid(ctx, logicalWidth, logicalHeight);

graphItems.value.forEach(item => {
ctx.beginPath();
ctx.arc(item.point.x, item.point.y, item.radius, 0, Math.PI * 2);
ctx.fillStyle = item.color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
});
};

// hold drawGrid as a separate function for clarity
const drawGrid = (ctx: CanvasRenderingContext2D, w: number, h: number) => {
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.font = '10px sans-serif';
ctx.fillStyle = '#94a3b8';

config.gridLines.forEach(m => {
const height = h - config.padding.top - config.padding.bottom;
const y = h - config.padding.bottom - (m / 100) * height;
ctx.beginPath();
ctx.moveTo(config.padding.left, y);
ctx.lineTo(w - config.padding.right, y);
ctx.stroke();
ctx.fillText(`${m}%`, 10, y + 3);
});
};

return {
graphItems,
updateData,
draw
};
}
19 changes: 7 additions & 12 deletions apps/frontend/src/composables/useItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,23 +170,18 @@ export function useItems() {
}

// Create a new item
async function createItem(
async function createItem(payload: {
title: string,
description: string | null,
motivation: number | null,
due: string,
durationMinutes?: number | null
): Promise<string> {
}): Promise<string> {
error.value = null;
try {
const id = await itemRepository.createItem({
title,
motivation,
due,
durationMinutes,
});
// Refresh items after creation
await fetchActiveItems();
return id;
try {
const id = await itemRepository.createItem(payload);
await fetchActiveItems();
return id;
} catch (err) {
error.value = String(err);
console.error('Failed to create item:', err);
Expand Down
45 changes: 45 additions & 0 deletions apps/frontend/src/types/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Item } from './item';

/**
* graph rendering data types
*/
export interface Point {
x: number;
y: number;
}

/**
* metadata for rendering an item on the graph (position, color, etc.)
* This is derived from the base Item data but includes additional properties needed for visualization.
*/
export interface GraphItem extends Item {
point: Point;
radius: number;
color: string;
}

/**
* range & grid lines configuration for the graph
*/
export interface GraphConfig {
padding: {
top: number;
right: number;
bottom: number;
left: number;
};
gridLines: number[]; // [0, 25, 50, 75, 100] など
defaultMotivation: number;
}

/**
* interaction state for the graph (hovered/selected item, zoom range, etc.)
*/
export interface GraphState {
hoveredItemId: string | null;
selectedItemId: string | null;
viewRange: {
start: Date;
end: Date;
} | null;
}
Loading