Compare commits

..

22 Commits

Author SHA1 Message Date
Julien Calixte
7a3dc1a47c chore: bump version to 1.0.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 01:03:56 +01:00
Julien Calixte
570f605ac7 fix: restore forced install phase for linux native bindings
supportedArchitectures alone is not sufficient; force reinstall is
needed to fetch the correct linux binding at build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:59:39 +01:00
Julien Calixte
da0231b427 i18n: better name 2026-03-15 00:57:38 +01:00
Julien Calixte
9c49abfff2 fix: swap open url and copy url button order
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:45:28 +01:00
Julien Calixte
3b9b26f993 feat: add hint to shrink window to hide config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:40:48 +01:00
Julien Calixte
1d43b87836 feat: add open url button to open countdown link in new tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:36:58 +01:00
Julien Calixte
d184a8339c chore: remove custom install phase, nixpacks default is sufficient
supportedArchitectures in package.json ensures the linux binding is in
the lockfile, so a standard frozen install now works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:34:48 +01:00
Julien Calixte
bf9f27068d fix: force pnpm install to bypass cached store missing linux binding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:33:24 +01:00
Julien Calixte
e4fb05aa27 fix: include linux-x64 in pnpm supportedArchitectures for rolldown binding
Ensures @rolldown/binding-linux-x64-gnu is fetched even when installing
on a non-linux host, fixing the nixpacks Docker build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:31:36 +01:00
Julien Calixte
251ffa2e1a fix: add install phase with --no-frozen-lockfile for linux native bindings
rolldown requires platform-specific bindings (@rolldown/binding-linux-x64-gnu)
that are absent from a lockfile generated on a different OS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:29:14 +01:00
Julien Calixte
2986a5d0fe fix: use nodejs_23 for vite 8 compatibility
Vite 8 requires Node.js 20.19+ or 22.12+; nodejs_22 in nixpkgs resolves to 22.11.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:25:46 +01:00
Julien Calixte
025cf4072e add nixpacks config and update dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:22:10 +01:00
Julien Calixte
85344ef306 feat: i don't know 2026-03-15 00:06:32 +01:00
Julien Calixte
3066c95563 💬 (no target) add a way to set a target to the user 2023-04-26 23:02:04 +02:00
Julien Calixte
4fff32abcc no autocomplete 2023-03-19 12:28:27 +01:00
Julien Calixte
2bbe912d9f remove vite svg and add a custom favicon 2023-03-19 12:27:02 +01:00
Julien Calixte
9689806680 Merge branch 'master' of github.com:jcalixte/miniminu 2023-03-19 12:13:25 +01:00
Julien Calixte
f02d8aeecc add input style 2023-03-19 12:13:23 +01:00
Julien Calixte
8c4911adf6 sort imports 2023-03-18 14:59:39 +01:00
Julien Calixte
9e6caa46a7 Merge branch 'master' of github.com:jcalixte/miniminu 2023-03-18 14:17:44 +01:00
Julien Calixte
fd6b9fbe83 fix test for the complex target date 2023-03-18 14:17:41 +01:00
Julien Calixte
784e54bbf8 display when the target has passed 2023-03-18 14:17:09 +01:00
14 changed files with 1086 additions and 956 deletions

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v23

View File

@@ -1,18 +1,40 @@
# Vue 3 + TypeScript + Vite # miniminu - the milestone reminder
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. A minimal countdown timer app. Set a target date and watch the time remaining displayed in years, months, days, hours, minutes, and seconds.
## Recommended IDE Setup ## Features
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). - Countdown to any target date
- Optional project title (shown as page title)
- Shareable URL — state is stored in query params (`?project=...&target=...`)
- Responsive: form controls are hidden on small screens, visible on 600px+
## Type Support For `.vue` Imports in TS ## Usage
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. Open the app in a browser. On a wide enough screen, fill in the **Title** and **Target date** fields, then click **copy url** to share a link with the countdown pre-configured.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: URL params:
- `project` — display name / page title
- `target` — target date in `YYYY-MM-DD` format
1. Disable the built-in TypeScript Extension ## Development
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` ```bash
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. pnpm install
pnpm dev
```
## Build
```bash
pnpm build
pnpm preview
```
## Tech stack
- [Vue 3](https://vuejs.org/) with `<script setup>` SFCs
- [TypeScript](https://www.typescriptlang.org/)
- [Vite 8](https://vite.dev/)
- [Luxon](https://moment.github.io/luxon/) for date/time calculations
- [VueUse](https://vueuse.org/) for URL search params and title sync

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Miniminu</title> <title>Miniminu</title>
</head> </head>

8
nixpacks.toml Normal file
View File

@@ -0,0 +1,8 @@
[phases.setup]
nixPkgs = ["nodejs_23", "pnpm"]
[phases.install]
cmds = ["pnpm install --no-frozen-lockfile --force"]
[phases.build]
cmds = ["pnpm build"]

View File

@@ -1,7 +1,7 @@
{ {
"name": "miniminu", "name": "miniminu",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,17 +10,24 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^9.13.0", "@vueuse/core": "^14.2.1",
"luxon": "^3.3.0", "luxon": "^3.7.2",
"vue": "^3.2.45", "vue": "^3.5.30",
"vue-router": "4" "vue-router": "4"
}, },
"pnpm": {
"supportedArchitectures": {
"os": ["current", "linux"],
"cpu": ["current", "x64"],
"libc": ["current", "glibc"]
}
},
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.2.0", "@types/luxon": "^3.7.1",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^6.0.5",
"typescript": "^4.9.3", "typescript": "^5.9.3",
"vite": "^4.1.0", "vite": "^8.0.0",
"vitest": "^0.29.2", "vitest": "^4.1.0",
"vue-tsc": "^1.0.24" "vue-tsc": "^3.2.5"
} }
} }

1822
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,26 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue" import { useTitle, useUrlSearchParams } from "@vueuse/core"
import { useTitle } from "@vueuse/core" import { computed, ref, watch } from "vue"
import { useTimeUntil } from "../hooks/useTimeUntil.hooks" import { useTimeUntil } from "../hooks/useTimeUntil.hooks"
const props = defineProps<{ project?: string; target?: string }>() const props = defineProps<{ project?: string; target?: string }>()
const searchParams = useUrlSearchParams<{ project?: string; target?: string }>(
"history",
)
const target = computed(() => props.target) const projectTitle = ref(props.project)
const targetInput = ref(props.target)
if (props.project) { watch(
useTitle(props.project) projectTitle,
() => {
searchParams.project = projectTitle.value
},
{
immediate: true,
},
)
watch(
targetInput,
() => {
searchParams.target = targetInput.value
},
{
immediate: true,
},
)
const target = computed(() => targetInput.value)
if (projectTitle.value) {
useTitle(projectTitle.value)
} }
const targetDate = computed(() => const targetDate = computed(() =>
props.target targetInput.value
? new Date(props.target).toLocaleDateString(undefined, { ? new Date(targetInput.value).toLocaleDateString(undefined, {
dateStyle: "long", dateStyle: "long",
}) })
: null : null,
) )
const { const {
timeUntilTarget, timeUntilTarget,
hasTargetPassed,
isYearsDisplayed, isYearsDisplayed,
isMonthsDisplayed, isMonthsDisplayed,
isDaysDisplayed, isDaysDisplayed,
@@ -35,8 +62,6 @@ const {
secondsUntil, secondsUntil,
} = useTimeUntil(target) } = useTimeUntil(target)
const projectTitle = ref(props.project)
const targetInput = ref(props.target)
const url = computed(() => { const url = computed(() => {
const newUrl = new URL(document.location.toString()) const newUrl = new URL(document.location.toString())
if (projectTitle.value) { if (projectTitle.value) {
@@ -58,7 +83,7 @@ const copyUrl = () => {
<template> <template>
<div class="responsive-time-until"> <div class="responsive-time-until">
<h1 v-if="props.project">{{ props.project }}</h1> <h1 v-if="projectTitle">{{ projectTitle }}</h1>
<section class="time" v-if="timeUntilTarget"> <section class="time" v-if="timeUntilTarget">
<div v-if="isYearsDisplayed" class="count"> <div v-if="isYearsDisplayed" class="count">
<span class="number">{{ yearsUntil }}</span> <span class="number">{{ yearsUntil }}</span>
@@ -85,26 +110,46 @@ const copyUrl = () => {
<span class="moment">seconds</span> <span class="moment">seconds</span>
</div> </div>
</section> </section>
<section v-else>No target set.</section> <section v-else class="no-target">
<section v-if="targetDate" class="target-date">{{ targetDate }}</section> No target set. Expand window to set a target.
</section>
<section v-if="targetDate" class="target-date">
<div v-if="hasTargetPassed" class="has-target-passed">🎊</div>
<hr v-else />
{{ targetDate }}
</section>
<form @submit.prevent> <form @submit.prevent>
<div> <div>
<label for="title">Title:</label> <label for="title">Title:</label>
<input type="text" id="title" v-model="projectTitle" /> <input
type="text"
id="title"
v-model="projectTitle"
autocomplete="false"
/>
</div> </div>
<div> <div>
<label for="target">Target date:</label> <label for="target">Milestone:</label>
<input type="date" id="target" v-model="targetInput" /> <input
type="date"
id="target"
v-model="targetInput"
autocomplete="false"
/>
</div> </div>
<div> <div>
<a :href="url" target="_blank" rel="noopener noreferrer"
><button type="button">open url</button></a
>
<button @click="copyUrl">copy url</button> <button @click="copyUrl">copy url</button>
</div> </div>
<p class="hint">Shrink the window to hide this config.</p>
</form> </form>
</div> </div>
</template> </template>
<style scoped> <style scoped>
div.responsive-time-until { .responsive-time-until {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -133,6 +178,14 @@ div.responsive-time-until {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.no-target {
padding: 1rem;
}
.has-target-passed {
font-size: 72px;
}
form { form {
display: none; display: none;
padding: 1rem; padding: 1rem;
@@ -158,4 +211,12 @@ input {
display: flex; display: flex;
} }
} }
.hint {
font-size: 0.75rem;
opacity: 0.5;
width: 100%;
text-align: center;
margin: 0;
}
</style> </style>

View File

@@ -1,8 +1,11 @@
import { ref, computed, onUnmounted, ComputedRef } from "vue" import { ref, computed, onUnmounted, ComputedRef } from "vue"
import { timeUntil } from "../services/time-until" import { hasTimePassed, timeUntil } from "../services/time-until"
export const useTimeUntil = (target: ComputedRef<string | undefined>) => { export const useTimeUntil = (target: ComputedRef<string | undefined>) => {
const timeUntilTarget = target.value ? timeUntil(target.value) : null const timeUntilTarget = target.value ? timeUntil(target.value) : null
const hasTargetPassed = timeUntilTarget
? hasTimePassed(timeUntilTarget)
: false
const yearsUntil = ref(timeUntilTarget?.years ?? 0) const yearsUntil = ref(timeUntilTarget?.years ?? 0)
const monthsUntil = ref(timeUntilTarget?.months ?? 0) const monthsUntil = ref(timeUntilTarget?.months ?? 0)
@@ -46,6 +49,7 @@ export const useTimeUntil = (target: ComputedRef<string | undefined>) => {
return { return {
timeUntilTarget, timeUntilTarget,
hasTargetPassed,
isYearsDisplayed, isYearsDisplayed,
isMonthsDisplayed, isMonthsDisplayed,
isDaysDisplayed, isDaysDisplayed,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { hasPassed, timeUntil } from "./time-until" import { hasTimePassed, timeUntil } from "./time-until"
const noTime = () => ({ const noTime = () => ({
days: 0, days: 0,
@@ -42,8 +42,8 @@ describe("time until", () => {
months: 10, months: 10,
days: 30, days: 30,
hours: 10, hours: 10,
minutes: 9, minutes: 34,
seconds: 2062, seconds: 22,
}) })
}) })
@@ -57,9 +57,9 @@ describe("time until", () => {
describe("has passed", () => { describe("has passed", () => {
it("tells if the targed has passed", () => { it("tells if the targed has passed", () => {
expect(hasPassed(noTime())).toBeTruthy() expect(hasTimePassed(noTime())).toBeTruthy()
expect( expect(
hasPassed({ hasTimePassed({
years: 0, years: 0,
months: 0, months: 0,
days: 0, days: 0,

View File

@@ -38,5 +38,5 @@ export const timeUntil = (target: string): TimeUntilReturn => {
} }
} }
export const hasPassed = (timeUntil: TimeUntilReturn): boolean => export const hasTimePassed = (timeUntil: TimeUntilReturn): boolean =>
Object.entries(timeUntil).every(([_, value]) => value === 0) Object.entries(timeUntil).every(([_, value]) => value === 0)

View File

@@ -79,3 +79,24 @@ button:focus-visible {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
} }
input {
position: relative;
cursor: text;
font-size: 14px;
line-height: 14px;
padding: 0 16px;
height: 18px;
background-color: #f9f9f9;
border: 1px solid #213547;
border-radius: 3px;
color: rgb(35, 38, 59);
box-shadow: inset 0 1px 4px 0 rgb(119 122 175 / 30%);
overflow: hidden;
transition: all 100ms ease-in-out;
}
input:focus {
border-color: #111c25;
box-shadow: 0 1px 0 0 rgb(35 38 59 / 5%);
}