Skip to content
Draft
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
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="./icon-any-32.svg" />

<!-- App version for update detection -->
<meta name="app-version" content="__APP_VERSION__" />

<title>Coil</title>
</head>
<body class="m-0 overflow-hidden overscroll-none">
Expand Down
9,219 changes: 6,703 additions & 2,516 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"prettier": "^3.6.2",
"tailwindcss": "^4.1.11",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
"vite": "^7.0.6",
"vite-plugin-pwa": "^1.0.2",
"workbox-window": "^7.3.0"
}
}
48 changes: 48 additions & 0 deletions src/PWANotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Download, X } from 'lucide-react';
import { usePWA } from './usePWA';

export default function PWANotifications() {
const { updateAvailable, offlineReady, handleUpdate, dismissUpdate, dismissOfflineReady } = usePWA();

if (!updateAvailable && !offlineReady) {
return null;
}

return (
<div className="fixed bottom-4 left-4 right-4 z-50 flex justify-center">
{updateAvailable && (
<div className="bg-blue-600 text-white rounded-lg shadow-lg p-4 max-w-sm w-full flex items-center gap-3">
<Download size={20} />
<div className="flex-1">
<p className="font-semibold text-sm">Update Available</p>
<p className="text-xs opacity-90">A new version of Coil is ready to install.</p>
</div>
<div className="flex gap-2">
<button
onClick={handleUpdate}
className="bg-blue-700 hover:bg-blue-800 px-3 py-1 rounded text-xs font-medium transition-colors"
>
Update
</button>
<button onClick={dismissUpdate} className="p-1 hover:bg-blue-700 rounded transition-colors">
<X size={16} />
</button>
</div>
</div>
)}

{offlineReady && !updateAvailable && (
<div className="bg-green-600 text-white rounded-lg shadow-lg p-4 max-w-sm w-full flex items-center gap-3">
<div className="w-2 h-2 bg-green-300 rounded-full"></div>
<div className="flex-1">
<p className="font-semibold text-sm">Ready for Offline Use</p>
<p className="text-xs opacity-90">Coil is now available offline.</p>
</div>
<button onClick={dismissOfflineReady} className="p-1 hover:bg-green-700 rounded transition-colors">
<X size={16} />
</button>
</div>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions src/SpiralTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AnimatedColon } from './AnimatedColon';
import { ClockFace, ClockFaceHandle } from './ClockFace';
import { HelpScreen } from './HelpScreen';
import { JogDial, JogEvent } from './JogDial';
import PWANotifications from './PWANotifications';
import {
formatDuration,
formatDurationMinutes,
Expand Down Expand Up @@ -622,6 +623,8 @@ const SpiralTimer = () => {
</ToolbarButton>
)}
</Toolbar>

<PWANotifications />
</div>
);
};
Expand Down
60 changes: 60 additions & 0 deletions src/usePWA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';

export function usePWA() {
const [updateAvailable, setUpdateAvailable] = useState(false);

const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
console.log('SW Registered: ' + r);

// Check for updates every 10 minutes
if (r) {
setInterval(
() => {
r.update();
},
10 * 60 * 1000,
);
}
},
onRegisterError(error) {
console.log('SW registration error', error);
},
onOfflineReady() {
console.log('SW offline ready');
setOfflineReady(true);
},
onNeedRefresh() {
console.log('SW need refresh');
setNeedRefresh(true);
setUpdateAvailable(true);
},
});

const handleUpdate = () => {
updateServiceWorker(true);
};

const dismissUpdate = () => {
setUpdateAvailable(false);
setNeedRefresh(false);
};

const dismissOfflineReady = () => {
setOfflineReady(false);
};

return {
updateAvailable,
offlineReady,
needRefresh,
handleUpdate,
dismissUpdate,
dismissOfflineReady,
};
}
23 changes: 23 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

declare const __APP_VERSION__: string;

declare module 'virtual:pwa-register/react' {
import type { RegisterSWOptions } from 'vite-plugin-pwa/types';

export type { RegisterSWOptions };

export function useRegisterSW(options?: RegisterSWOptions): {
needRefresh: [boolean, (value: boolean) => void];
offlineReady: [boolean, (value: boolean) => void];
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
};
}
80 changes: 79 additions & 1 deletion vite.config.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,91 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { execSync } from 'child_process';

import { defineConfig } from 'vite';

// Get git commit hash for version tracking
const getGitCommitHash = () => {
try {
return execSync('git rev-parse HEAD').toString().trim();
} catch {
return 'development';
}
};

export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'prompt',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
],
},
includeAssets: ['apple-touch-icon.png', 'icon-*.svg', 'social-preview.png'],
manifest: {
name: 'Coil',
short_name: 'Coil',
description: 'Distraction-free visual timer',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: 'icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'maskable',
},
{
src: 'icon-32.svg',
sizes: '32x32',
type: 'image/svg+xml',
purpose: 'maskable',
},
{
src: 'icon-any-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: 'icon-any-32.svg',
sizes: '32x32',
type: 'image/svg+xml',
purpose: 'any',
},
],
},
}),
// Plugin to replace __APP_VERSION__ in HTML
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace('__APP_VERSION__', getGitCommitHash());
},
},
],
base: process.env.NODE_ENV === 'production' ? '/coil-timer/' : '/',
server: {
host: '0.0.0.0',
port: 5173,
},
define: {
__APP_VERSION__: JSON.stringify(getGitCommitHash()),
},
});