initial commit

This commit is contained in:
Ben
2025-10-10 05:14:58 +02:00
commit 9bef7404b9
17 changed files with 4000 additions and 0 deletions

24
runninglog-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3545
runninglog-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "runninglog-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"dayjs": "^1.11.13",
"tailwindcss": "^4.1.11",
"vue": "^3.5.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.0.5"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,28 @@
<template>
<div id="app" class="w-full h-screen flex flex-col">
<div class="flex flex-col h-full">
<!-- App header -->
<header class="h-12 border-b flex items-center px-4">
App Header
</header>
<!-- calendar area fills remaining space -->
<main class="flex-1 overflow-hidden">
<Calendar @request-load="onRequestLoad" />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import Calendar from './components/Calendar.vue'
function onRequestLoad(dates: string[]) {
// Example: fetch events for `dates` from API
console.log('request-load', dates)
}
</script>
<style scoped>
/* nothing special here */
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,223 @@
<template>
<div class="h-full">
<div
ref="scrollContainer"
class="relative h-full overflow-y-auto border"
@scroll="onScroll"
>
<!-- sticky header -->
<div class="sticky top-0 z-10 border-b">
<div class="grid grid-cols-7 text-center font-medium bg-white dark:bg-gray-800 shadow-sm">
<div v-for="day in days" :key="day" class="py-2 border-r last:border-r-0">
{{ day }}
</div>
</div>
</div>
<!-- top spacer -->
<div :style="{ height: topSpacerHeight + 'px' }"></div>
<!-- weeks -->
<div
v-for="week in visibleWeeks"
:key="week.id"
class="grid grid-cols-7"
role="row"
:data-testid="'week-' + week.id"
>
<div
v-for="(day, dayIndex) in week.days"
class="h-30 flex items-center justify-center"
:class="{
// bottom
'border-b-thick': borderStyles[week.id*DAYS_IN_WEEK + dayIndex].bottom,
'border-b-regular': !borderStyles[week.id*DAYS_IN_WEEK + dayIndex].bottom,
// right
'border-r-none': dayIndex !== 6,
'border-r-regular': dayIndex === 6,
// left
'border-l-thick': borderStyles[week.id*DAYS_IN_WEEK + dayIndex].left,
'border-l-regular': !borderStyles[week.id*DAYS_IN_WEEK + dayIndex].left,
// top
'border-t-none': true,
// backgrounds
'bg-calendar-first-day': day.date() === 1,
'bg-calendar': day.date() !== 1
}"
role="gridcell"
:data-testid="'day-'+dayIndex"
>
{{day.format("MMM DD YYYY")}}
</div>
</div>
<!-- bottom spacer -->
<div :style="{ height: bottomSpacerHeight + 'px' }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
// Emits type (keeps your existing API)
const emit = defineEmits<{ (e: 'request-load', dates: string[]): void }>()
interface Week { id: number, days: Dayjs[] }
type MonthBorderInfo = {
bottom: boolean;
right: boolean;
top: boolean;
left: boolean;
}
const scrollContainer = ref<HTMLDivElement | null>(null);
const weekHeight = 120; // px (Tailwind h-20)
const buffer = 5;
const totalWeeks = 4_000;
const DAYS_RAW: string[] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DAYS_IN_WEEK = DAYS_RAW.length;
const startDateApprox = ref<Dayjs>(dayjs("2010-01-04")); // Maybe we load this from app or elsewhere
const loadDate = ref<Dayjs>(dayjs("2025-10-03")); // Maybe we load this from app or elsewhere
const weekStartDay = ref<number>(1); // maybe we load this from user settings
function getStartOfWeek(date: Dayjs): Dayjs{
const dayOfWeek = (date.get('day') - weekStartDay.value + DAYS_IN_WEEK) % DAYS_IN_WEEK;
return date.subtract(dayOfWeek, 'day');
}
const startDate = computed<Dayjs>(() => {
return getStartOfWeek(startDateApprox.value);
});
const loadIndex = computed<number>(() =>{
const loadDateFirst = getStartOfWeek(loadDate.value);
return loadDateFirst.diff(startDate.value, 'weeks');
});
const days = computed<string[]>(() => {
return DAYS_RAW.slice(weekStartDay.value, DAYS_IN_WEEK).concat(DAYS_RAW.slice(0, weekStartDay.value));
});
const borderStyles = computed(() => {
const styles: Record<number, MonthBorderInfo> = {}
for (let weekIndex = 0; weekIndex < totalWeeks; weekIndex++) {
for (let dayIndex = 0; dayIndex < DAYS_IN_WEEK; dayIndex++) {
const totalDayIndex = weekIndex*7 + dayIndex;
const day = startDate.value.add(totalDayIndex, 'day');
styles[totalDayIndex] = {
bottom: day.month() !== day.add(7, "day").month(),
right: dayIndex < 6 && day.month() !== day.add(1, "day").month(),
top: day.month() !== day.subtract(7, "day").month(),
left: dayIndex > 0 && day.month() !== day.subtract(1, "day").month()
}
}
}
return styles
})
const visibleCount = ref<number>(0)
const scrollTop = ref<number>(0)
let resizeObserver: ResizeObserver | null = null
function calculateVisibleCount() {
if (!scrollContainer.value) return
//console.log(scrollContainer.value.clientHeight);
visibleCount.value =
Math.ceil(scrollContainer.value.clientHeight / weekHeight) + buffer * 2
}
function onScroll(): void {
if (!scrollContainer.value) return
console.log("scroll")
scrollTop.value = scrollContainer.value.scrollTop
}
onMounted(async () => {
if (!scrollContainer.value) return
// Observe size changes so visibleCount stays correct
// resizeObserver = new ResizeObserver(() => {
// console.log("resize")
// calculateVisibleCount()
// })
// resizeObserver.observe(scrollContainer.value)
// initial visible count
calculateVisibleCount()
// wait a tick then center the scroll position
await nextTick()
// center so we can scroll up and down
scrollContainer.value.scrollTop =
loadIndex.value * weekHeight - scrollContainer.value.clientHeight / 2
// sync reactive scrollTop
scrollTop.value = scrollContainer.value.scrollTop
// quick debug info (remove in production)
// eslint-disable-next-line no-console
console.log('calendar mounted', {
clientHeight: scrollContainer.value.clientHeight,
visibleCount: visibleCount.value,
})
})
onBeforeUnmount(() => {
if (resizeObserver && scrollContainer.value) {
resizeObserver.unobserve(scrollContainer.value)
resizeObserver.disconnect()
resizeObserver = null
}
})
const firstVisibleIndex = computed<number>(() =>
Math.max(0, Math.floor(scrollTop.value / weekHeight) - buffer)
)
const visibleWeeks = computed<Week[]>(() => {
const weeks: Week[] = []
for (let i = 0; i < visibleCount.value; i++) {
const index = firstVisibleIndex.value + i
if (index >= totalWeeks) break
weeks.push({
id: index,
days: generateDays(index)
})
}
return weeks
})
function generateDays(weekIndex: number): Dayjs[]{
const weekStart = startDate.value.add(weekIndex*DAYS_IN_WEEK, 'day');
const ret: Dayjs[] = []
for (let i = 0; i < DAYS_IN_WEEK; i++)
{
ret.push(weekStart.add(i, 'day'));
}
return ret;
}
const topSpacerHeight = computed<number>(() => firstVisibleIndex.value * weekHeight)
const bottomSpacerHeight = computed<number>(() =>
Math.max(
0,
totalWeeks - (firstVisibleIndex.value + visibleWeeks.value.length)
) * weekHeight
)
</script>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,67 @@
@import "tailwindcss";
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #1e2942;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--calendar-first-day-bg: hsl(266, 57%, 14%); /* slightly darker background */
--calendar-border-thick: 2px solid rgba(244, 240, 240, 0.8);
--calendar-border-regular: 2px solid rgba(104, 93, 93, 0.8);
--calendar-bg: rgb(51, 27, 82);
}
.bg-calendar-first-day {
background-color: var(--calendar-first-day-bg);
}
.bg-calendar{
background-color: var(--calendar-bg);
}
.border-b-thick {
border-bottom: var(--calendar-border-thick);
}
.border-r-none {
border-right: none;
}
.border-t-none {
border-top: none;
}
.border-l-thick {
border-left: var(--calendar-border-thick);
}
.border-b-regular {
border-bottom: var(--calendar-border-regular);
}
.border-r-regular {
border-right: var(--calendar-border-regular);
}
.border-t-regular {
border-top: var(--calendar-border-regular);
}
.border-l-regular {
border-left: var(--calendar-border-regular);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
@media (prefers-color-scheme: light) {
:root {
color: #16222d;
background-color: #ffffff;
}
}

1
runninglog-frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -0,0 +1,9 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"allowJs": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vueDevTools from 'vite-plugin-vue-devtools'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss(), vueDevTools()],
})