Google Spreadsheet 팝업 — onOpen 은 파일을 열 때 도는 예약 이름(단순 트리거)이라 메뉴를 거기서 붙인다.1
function showAlert() {
const ui = SpreadsheetApp.getUi()
ui.alert('팝업 창', '표시할 텍스트', ui.ButtonSet.OK)
}
// 메뉴에 추가
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('팝업 메뉴')
.addItem('팝업 띄우기', 'showAlert')
.addToUi()
}
Footnotes
-
Simple Triggers - Apps Script: “onOpen(e) runs when a user opens a spreadsheet, document, presentation, or form that the user has permission to edit.” / Class Ui: “Opens a dialog box in the user’s editor with the given title, message, and set of buttons.” ↩
특수 기호 제거
function removeSpecialCharacters(input) {
return input.replace(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/g, '')
}
removeSpecialCharacters('Hello_World123! 안녕하세요?')
// "HelloWorld123 안녕하세요"
조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.
function renderA() {
return <Item isPacked={true} name="Space suit" />
}
function renderB() {
return <>{isPacked ? <Item name="Space suit" /> : null}</>
}
react.dev 는 A 가 흔치 않고 B 가 더 흔하다고 적는다.1
조건부 렌더링을 보다 간결하게 표현하기 위해 If, Then, 그리고 Else 컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 If에서 condition prop을 통해 조건을 받아들이고, 자식으로 Then과 Else를 받아 각각의 내용을 렌더링하도록 한다.
type Props = {
/** 렌더링할 조건 */
condition: boolean
/** 자식 컴포넌트 (Then, Else 포함) */
children: React.ReactNode
}
/**
* If 컴포넌트는 조건부 렌더링을 위한 컴포넌트
*/
function If({ condition, children }: Props) {
let thenChild = null
let elseChild = null
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return
if (child.type === Then) thenChild = child
if (child.type === Else) elseChild = child
})
return condition ? thenChild : elseChild
}
/**
* Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함
*/
const Then = ({ children }: React.PropsWithChildren) => <>{children}</>
const Else = ({ children }: React.PropsWithChildren) => <>{children}</>
Footnotes
-
Conditional Rendering – React: “In practice, returning
nullfrom a component isn’t common because it might surprise a developer trying to render it. More often, you would conditionally include or exclude the component in the parent component’s JSX.” ↩
MongoDB TTL 인덱스 (자동 문서 삭제)1
// 데이터 삽입
db.collection.insertOne({
name: 'Temporary',
expireAt: new Date('2024-11-30T00:00:00Z'),
})
// TTL 인덱스 생성 (expireAt 시간 기준 삭제)
db.collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
SQLite는 TTL 미지원 → 트리거 또는 외부 스케줄러로 구현
Footnotes
-
Expire Data from Collections by Setting TTL | MongoDB: “To expire documents at a specific clock time, create a TTL index on a field that holds values of BSON date type or an array of BSON date-typed objects, and specify an
expireAfterSecondsvalue of0.” ↩ -
TTL Indexes | MongoDB: “The background task that removes expired documents runs every 60 seconds.” ↩
-
TTL Indexes | MongoDB: “If the indexed field in a document doesn’t contain one or more date values, the document will not expire.” ↩
SQLite 게시물 업데이트 내역 저장
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE post_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
title TEXT,
content TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);
-- 업데이트 전 내역 저장
INSERT INTO post_history (post_id, title, content, updated_at)
SELECT id, title, content, updated_at FROM posts WHERE id = 1;
-- 게시물 업데이트
UPDATE posts SET title = '새 제목', updated_at = CURRENT_TIMESTAMP WHERE id = 1;
posts : post_history = 1 : N 관계
SQLite JSON 태그 빈도 정렬 — json_each 가 배열을 원소당 한 행으로 펼친다. 다시 묶을 때 순서는 집계 함수 안의 ORDER BY 로만 보장된다1
-- 태그별 빈도 계산
SELECT json_each.value AS tag, COUNT(*) AS frequency
FROM example, json_each(example.tags)
GROUP BY json_each.value
ORDER BY frequency DESC, tag ASC;
-- 빈도순 정렬된 태그를 JSON 배열로 (집계 안 ORDER BY — SQLite 3.44+)
SELECT json_group_array(tag ORDER BY frequency DESC, tag ASC) AS tags_by_frequency
FROM (
SELECT json_each.value AS tag, COUNT(*) AS frequency
FROM example, json_each(example.tags)
GROUP BY json_each.value
);
Footnotes
-
Built-in Aggregate Functions - SQLite: “If no ORDER BY clause is specified, the inputs to the aggregate occur in an arbitrary order that might change from one invocation to the next.” · 3.44.0 릴리스 노트: “Aggregate functions can now include an ORDER BY clause after their last parameter.” ↩
빈 PDF 파일이 필요해서 찾아본 방법들
import { Canvas } from 'skia-canvas'
import { writeFile } from 'node:fs/promises'
/**
* A4 크기의 빈 PDF 파일을 생성합니다.
*
* @param {string} filename - 생성할 PDF 파일의 이름입니다.
* @returns {Promise<void>}
*/
async function createEmptyPDF(filename) {
// A4 크기 (595x842 포인트)
const width = 595
const height = 842
// 캔버스를 생성합니다
const canvas = new Canvas(width, height)
const ctx = canvas.getContext('2d')
// 아무 내용도 그리지 않고 현재 상태를 저장합니다
ctx.save()
// 캔버스를 PDF 버퍼로 변환하여 저장합니다
const buffer = await canvas.toBuffer('pdf')
await writeFile(filename, buffer)
console.log(`${filename} 파일이 생성되었습니다!`)
}
createEmptyPDF('empty_skia.pdf')
skia-canvas 는 형식을 캔버스가 아니라 내보낼 때 정한다 — createCanvas(w, h, 'pdf') 는 node-canvas 의 API 라 여기엔 없다1.
# Ghostscript를 사용한 빈 PDF 생성
gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=empty.pdf -c "<< /PageSize [595 842] >> setpagedevice showpage"
# ImageMagick의 `convert` 명령어로 빈 PDF 생성
convert xc:white -page A4 empty.pdf
Footnotes
-
Canvas | skia-canvas: “Node
Bufferobjects containing various image formats can be created by passing either a format string like"svg"or a mime-type like"image/svg+xml".” ↩
Apps Script로 캘린더 예약을 만들 때 쓰는 건 결국 API 두 개다.
Calendar.Freebusy.query로 바쁜 일정을 받아 근무 요일·근무 시간과 겹치는 슬롯을 빼면 예약 가능한 슬롯이 남는다. 예약은 CalendarApp.getCalendarById로 이벤트를 만들고 sendInvites: true로 초대 메일까지 보낸다1.
Freebusy는 슬롯을 계산할 때와 실제로 예약할 때 두 번 조회한다 — 그 사이에 일정이 생길 수 있으니.2
Footnotes
-
Class Calendar · createEvent(title, startTime, endTime, options) | Apps Script: “Whether to send invitation emails (default:
false).” — 기본값이 꺼져 있다 ↩ -
someday
backend/src/app.ts— 슬롯 계산(fetchAvailability, L51)과 별도로bookTimeslot이 같은 구간을 다시 조회해busy가 있으면Timeslot not available을 던진다 ↩
AuthContext 테스트 패턴
// localStorage 함수 테스트
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
if (key === 'auth_token') return 'test_token'
return null
})
// AuthProvider 상태 테스트
const TestComponent = () => {
const { token, login, logout } = useAuth()
return (
<div>
<p data-testid="token">{token || 'null'}</p>
<button onClick={() => login('test_token')}>Login</button>
<button onClick={logout}>Logout</button>
</div>
)
}
// useAuth 훅 테스트
it('AuthProvider 외부에서 호출하면 에러', () => {
expect(() => renderHook(() => useAuth())).toThrowError()
})
it('AuthProvider 내부에서 정상 동작', () => {
const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current).toHaveProperty('token', null)
})
타이머를 제어하기 위해 vi.useFakeTimers1와 vi.advanceTimersByTime2을 사용하기.
describe('Countdown 컴포넌트', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('타이머가 동작하며 시간을 업데이트한다.', () => {
const endTime = new Date(Date.now() + 60000).toISOString()
const renderChild = vi.fn(({ targetRef }) => (
<div ref={targetRef} data-testid="countdown" />
))
render(<Countdown end_at={endTime}>{renderChild}</Countdown>)
expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()
act(() => {
vi.advanceTimersByTime(10000)
})
expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')
act(() => {
vi.advanceTimersByTime(50000)
})
expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')
})
})
Footnotes
-
vi.useFakeTimers | Vitest: “To enable mocking timers, you need to call this method. It will wrap all further calls to timers (such as
setTimeout,setInterval,clearTimeout,clearInterval,setImmediate,clearImmediate, andDate) untilvi.useRealTimers()is called.” ↩ -
vi.advanceTimersByTime | Vitest: “This method will invoke every initiated timer until the specified number of milliseconds is passed or the queue is empty - whatever comes first.” ↩